diff --git a/lib/charms/data_platform_libs/v0/data_interfaces.py b/lib/charms/data_platform_libs/v0/data_interfaces.py index 83e1cf3c..5be1d931 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 = 55 +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 ########################################################################## @@ -2071,6 +2078,7 @@ def __init__( requested_entity_secret: Optional[str] = None, requested_entity_name: Optional[str] = None, requested_entity_password: Optional[str] = None, + prefix_matching: Optional[str] = None, ): """Manager of base client relations.""" super().__init__(model, relation_name) @@ -2081,6 +2089,7 @@ def __init__( self.requested_entity_secret = requested_entity_secret self.requested_entity_name = requested_entity_name self.requested_entity_password = requested_entity_password + self.prefix_matching = prefix_matching if ( self.requested_entity_secret or self.requested_entity_name @@ -3258,6 +3267,14 @@ def requested_entity_secret_content(self) -> Optional[Dict[str, Optional[str]]]: logger.warning("Invalid requested-entity-secret: no entity name") return names + @property + def prefix_matching(self) -> Optional[str]: + """Returns the prefix matching strategy that were requested.""" + if not self.relation.app: + return None + + return self.relation.data[self.relation.app].get("prefix-matching") + class DatabaseEntityRequestedEvent(DatabaseProvidesEvent, EntityProvidesEvent): """Event emitted when a new entity is requested for use on this relation.""" @@ -3364,6 +3381,16 @@ def version(self) -> Optional[str]: return self.relation.data[self.relation.app].get("version") + @property + def prefix_databases(self) -> Optional[List[str]]: + """Returns a list of databases matching a prefix.""" + if not self.relation.app: + return None + + if prefixed_databases := self.relation.data[self.relation.app].get("prefix-databases"): + return prefixed_databases.split(",") + return [] + class DatabaseCreatedEvent(AuthenticationEvent, DatabaseRequiresEvent): """Event emitted when a new database is created for use on this relation.""" @@ -3381,6 +3408,10 @@ class DatabaseReadOnlyEndpointsChangedEvent(AuthenticationEvent, DatabaseRequire """Event emitted when the read only endpoints are changed.""" +class DatabasePrefixDatabasesChangedEvent(AuthenticationEvent, DatabaseRequiresEvent): + """Event emitted when the prefix databases are changed.""" + + class DatabaseRequiresEvents(RequirerCharmEvents): """Database events. @@ -3391,6 +3422,7 @@ class DatabaseRequiresEvents(RequirerCharmEvents): database_entity_created = EventSource(DatabaseEntityCreatedEvent) endpoints_changed = EventSource(DatabaseEndpointsChangedEvent) read_only_endpoints_changed = EventSource(DatabaseReadOnlyEndpointsChangedEvent) + prefix_databases_changed = EventSource(DatabasePrefixDatabasesChangedEvent) # Database Provider and Requires @@ -3416,6 +3448,18 @@ def set_database(self, relation_id: int, database_name: str) -> None: """ self.update_relation_data(relation_id, {"database": database_name}) + def set_prefix_databases(self, relation_id: int, databases: List[str]) -> None: + """Set a coma separated list of databases matching a prefix. + + This function writes in the application data bag, therefore, + only the leader unit can call it. + + Args: + relation_id: the identifier for a particular relation. + databases: list of database names matching the requested prefix. + """ + self.update_relation_data(relation_id, {"prefix-databases": ",".join(sorted(databases))}) + def set_endpoints(self, relation_id: int, connection_strings: str) -> None: """Set database primary connections. @@ -3588,6 +3632,7 @@ def __init__( requested_entity_secret: Optional[str] = None, requested_entity_name: Optional[str] = None, requested_entity_password: Optional[str] = None, + prefix_matching: Optional[str] = None, ): """Manager of database client relations.""" super().__init__( @@ -3601,6 +3646,7 @@ def __init__( requested_entity_secret, requested_entity_name, requested_entity_password, + prefix_matching, ) self.database = database_name self.relations_aliases = relations_aliases @@ -3700,6 +3746,10 @@ def __init__( f"{relation_alias}_read_only_endpoints_changed", DatabaseReadOnlyEndpointsChangedEvent, ) + self.on.define_event( + f"{relation_alias}_prefix_databases_changed", + DatabasePrefixDatabasesChangedEvent, + ) def _on_secret_changed_event(self, event: SecretChangedEvent): """Event notifying about a new value of a secret.""" @@ -3792,6 +3842,8 @@ def _on_relation_created_event(self, event: RelationCreatedEvent) -> None: event_data["entity-permissions"] = self.relation_data.entity_permissions if self.relation_data.requested_entity_secret: event_data["requested-entity-secret"] = self.relation_data.requested_entity_secret + if self.relation_data.prefix_matching: + event_data["prefix-matching"] = self.relation_data.prefix_matching # Create helper secret if needed if ( @@ -3884,32 +3936,22 @@ def _on_relation_changed_event(self, event: RelationChangedEvent) -> None: # To avoid unnecessary application restarts do not trigger other events. return - # Emit an endpoints changed event if the database - # added or changed this info in the relation databag. - if "endpoints" in diff.added or "endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("endpoints changed on %s", datetime.now()) - getattr(self.on, "endpoints_changed").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Emit the aliased event (if any). - self._emit_aliased_event(event, "endpoints_changed") - - # To avoid unnecessary application restarts do not trigger other events. - return - - # Emit a read only endpoints changed event if the database - # added or changed this info in the relation databag. - if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed: - # Emit the default event (the one without an alias). - logger.info("read-only-endpoints changed on %s", datetime.now()) - getattr(self.on, "read_only_endpoints_changed").emit( - event.relation, app=event.app, unit=event.unit - ) - - # Emit the aliased event (if any). - self._emit_aliased_event(event, "read_only_endpoints_changed") + for key, event_name in [ + ("endpoints", "endpoints_changed"), + ("read-only-endpoints", "read_only_endpoints_changed"), + ("prefix-databases", "prefix_databases_changed"), + ]: + # Emit a change event if the key changed. + if key in diff.added or key in diff.changed: + # Emit the default event (the one without an alias). + logger.info("%s changed on %s", key, datetime.now()) + getattr(self.on, event_name).emit(event.relation, app=event.app, unit=event.unit) + + # Emit the aliased event (if any). + self._emit_aliased_event(event, event_name) + + # To avoid unnecessary application restarts do not trigger other events. + return class DatabaseRequires(DatabaseRequirerData, DatabaseRequirerEventHandlers): @@ -3930,6 +3972,7 @@ def __init__( requested_entity_secret: Optional[str] = None, requested_entity_name: Optional[str] = None, requested_entity_password: Optional[str] = None, + prefix_matching: Optional[str] = None, ): DatabaseRequirerData.__init__( self, @@ -3946,6 +3989,7 @@ def __init__( requested_entity_secret, requested_entity_name, requested_entity_password, + prefix_matching, ) DatabaseRequirerEventHandlers.__init__(self, charm, self) @@ -4231,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: @@ -5257,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") @@ -5519,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") @@ -5664,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 4626f70c..586357b1 100644 --- a/lib/charms/grafana_k8s/v0/grafana_dashboard.py +++ b/lib/charms/grafana_k8s/v0/grafana_dashboard.py @@ -187,7 +187,6 @@ def __init__(self, *args): import uuid from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple - import yaml from cosl import DashboardPath40UID, LZMABase64 from cosl.types import type_convert_stored @@ -218,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 = 46 +LIBPATCH = 48 PYDEPS = ["cosl >= 0.0.50"] @@ -586,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) @@ -1636,29 +1645,60 @@ def _remove_all_dashboards_for_relation(self, relation: Relation) -> None: self.on.dashboards_changed.emit() # pyright: ignore def _to_external_object(self, relation_id, dashboard): + decompressed = LZMABase64.decompress(dashboard["content"]) + as_dict = json.loads(decompressed) + + dashboard_title = as_dict.get("title", "") + dashboard_uid = as_dict.get("uid", "") + + try: + dashboard_version = int(as_dict["version"]) + except (KeyError, ValueError): + logger.warning("Dashboard '%s' (uid '%s') is missing a '.version' field or is invalid (must be integer); using '0' as fallback", dashboard_title, dashboard_uid) + dashboard_version = 0 + return { "id": dashboard["original_id"], "relation_id": relation_id, "charm": dashboard["template"]["charm"], - "content": LZMABase64.decompress(dashboard["content"]), + "content": decompressed, + "dashboard_uid": dashboard_uid, + "dashboard_version": dashboard_version, + "dashboard_title": dashboard_title, } @property def dashboards(self) -> List[Dict]: """Get a list of known dashboards across all instances of the monitored relation. + Filters out dashboards with the same uid, keeping only the one with the highest version. + When more than one dashboard have the same uid and version, keep the first one when + sorted by (relation_id, content) in reverse lexicographic order (highest relid first). + Returns: a list of known dashboards. The JSON of each of the dashboards is available in the `content` field of the corresponding `dict`. """ - dashboards = [] + d: Dict[str, dict] = {} for _, (relation_id, dashboards_for_relation) in enumerate( self.get_peer_data("dashboards").items() ): for dashboard in dashboards_for_relation: - dashboards.append(self._to_external_object(relation_id, dashboard)) + obj = self._to_external_object(relation_id, dashboard) + + key = obj.get("dashboard_uid") + if key is None or str(key).strip() == "": + # At this point, we assume that a `.uid` is present so we do not render a fallback identifier here. Instead, we omit it. + logger.error("dashboard '%s' from relation id '%s' is missing a '.uid' field; omitted", obj["dashboard_title"], obj["relation_id"]) + continue + + if key in d: + d[key] = max(d[key], obj, key=lambda o: (o["dashboard_version"], o["relation_id"], o["content"])) + logger.warning("deduplicate dashboard '%s' (uid '%s') - kept version '%s' from relation id '%s'", d[key]["dashboard_title"], d[key]["dashboard_uid"], d[key]["dashboard_version"], d[key]["relation_id"]) + else: + d[key] = obj - return dashboards + return list(d.values()) def _get_stored_dashboards(self, relation_id: int) -> list: """Pull stored dashboards out of the peer data bucket.""" diff --git a/lib/charms/loki_k8s/v1/loki_push_api.py b/lib/charms/loki_k8s/v1/loki_push_api.py index 961fb7a2..3e2e9bd2 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/traefik_k8s/v2/ingress.py b/lib/charms/traefik_k8s/v2/ingress.py index d329866f..7ff4cebf 100644 --- a/lib/charms/traefik_k8s/v2/ingress.py +++ b/lib/charms/traefik_k8s/v2/ingress.py @@ -50,6 +50,7 @@ def _on_ingress_ready(self, event: IngressPerAppReadyEvent): def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): logger.info("This app no longer has ingress") """ + import ipaddress import json import logging @@ -71,6 +72,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): ) import pydantic +from ops import EventBase from ops.charm import CharmBase, RelationBrokenEvent, RelationEvent from ops.framework import EventSource, Object, ObjectEvents, StoredState from ops.model import ModelError, Relation, Unit @@ -84,7 +86,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 17 +LIBPATCH = 19 PYDEPS = ["pydantic"] @@ -95,7 +97,7 @@ def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent): BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} PYDANTIC_IS_V1 = int(pydantic.version.VERSION.split(".")[0]) < 2 -if PYDANTIC_IS_V1: +if PYDANTIC_IS_V1: # noqa from pydantic import validator input_validator = partial(validator, pre=True) @@ -111,8 +113,10 @@ class Config: _NEST_UNDER = None + # Annotating -> "DatabagModel" as the return type here doesn't sit well with pyright + # We are disabling this line for now and come back to it later. @classmethod - def load(cls, databag: MutableMapping): + def load(cls, databag: MutableMapping): # type: ignore[no-untyped-def] """Load this model from a Juju databag.""" if cls._NEST_UNDER: return cls.parse_obj(json.loads(databag[cls._NEST_UNDER])) @@ -136,7 +140,7 @@ def load(cls, databag: MutableMapping): log.debug(msg, exc_info=True) raise DataValidationError(msg) from e - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True) -> Any: """Write the contents of this model to Juju databag. :param databag: the databag to write the data to. @@ -152,7 +156,7 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): databag[self._NEST_UNDER] = self.json(by_alias=True, exclude_defaults=True) return databag - for key, value in self.dict(by_alias=True, exclude_defaults=True).items(): # type: ignore + for key, value in self.dict(by_alias=True, exclude_defaults=True).items(): # type: ignore # noqa databag[key] = json.dumps(value) return databag @@ -160,9 +164,9 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): else: from pydantic import ConfigDict, field_validator - input_validator = partial(field_validator, mode="before") + input_validator = partial(field_validator, mode="before") # type: ignore - class DatabagModel(BaseModel): + class DatabagModel(BaseModel): # type: ignore """Base databag model.""" model_config = ConfigDict( @@ -176,8 +180,10 @@ class DatabagModel(BaseModel): ) # type: ignore """Pydantic config.""" + # Annotating -> "DatabagModel" as the return type here doesn't sit well with pyright + # We are disabling this line for now and come back to it later. @classmethod - def load(cls, databag: MutableMapping): + def load(cls, databag: MutableMapping): # type: ignore[no-untyped-def] """Load this model from a Juju databag.""" nest_under = cls.model_config.get("_NEST_UNDER") if nest_under: @@ -202,7 +208,7 @@ def load(cls, databag: MutableMapping): log.debug(msg, exc_info=True) raise DataValidationError(msg) from e - def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): + def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True) -> Any: """Write the contents of this model to Juju databag. :param databag: the databag to write the data to. @@ -222,7 +228,11 @@ def dump(self, databag: Optional[MutableMapping] = None, clear: bool = True): ) return databag - dct = self.model_dump(mode="json", by_alias=True, exclude_defaults=True) # type: ignore + dct = self.model_dump( + mode="json", + by_alias=True, + exclude_defaults=True, # type: ignore + ) databag.update({k: json.dumps(v) for k, v in dct.items()}) return databag @@ -289,15 +299,17 @@ class IngressRequirerAppData(DatabagModel): default="http", description="What scheme to use in the generated ingress url" ) + # pydantic wants 'cls' as first arg @input_validator("scheme") - def validate_scheme(cls, scheme): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_scheme(cls, scheme: str) -> str: # noqa: N805 """Validate scheme arg.""" if scheme not in {"http", "https", "h2c"}: raise ValueError("invalid scheme: should be one of `http|https|h2c`") return scheme + # pydantic wants 'cls' as first arg @input_validator("port") - def validate_port(cls, port): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_port(cls, port: int) -> int: # noqa: N805 """Validate port.""" assert isinstance(port, int), type(port) assert 0 < port < 65535, "port out of TCP range" @@ -314,14 +326,16 @@ class IngressRequirerUnitData(DatabagModel): "IP can only be None if the IP information can't be retrieved from juju.", ) + # pydantic wants 'cls' as first arg @input_validator("host") - def validate_host(cls, host): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_host(cls, host: str) -> str: # noqa: N805 """Validate host.""" assert isinstance(host, str), type(host) return host + # pydantic wants 'cls' as first arg @input_validator("ip") - def validate_ip(cls, ip): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_ip(cls, ip: str) -> Optional[str]: # noqa: N805 """Validate ip.""" if ip is None: return None @@ -380,19 +394,19 @@ def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME) observe(charm.on.upgrade_charm, self._handle_upgrade_or_leader) # type: ignore @property - def relations(self): + def relations(self) -> List[Relation]: """The list of Relation instances associated with this endpoint.""" return list(self.charm.model.relations[self.relation_name]) - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation update.""" pass - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: """Subclasses should implement this method to handle a relation breaking.""" pass - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: """Subclasses should implement this method to handle upgrades or leadership change.""" pass @@ -402,10 +416,10 @@ class _IPAEvent(RelationEvent): __optional_kwargs__: Dict[str, Any] = {} @classmethod - def __attrs__(cls): + def __attrs__(cls): # type: ignore return cls.__args__ + tuple(cls.__optional_kwargs__.keys()) - def __init__(self, handle, relation, *args, **kwargs): + def __init__(self, handle, relation, *args, **kwargs): # type: ignore super().__init__(handle, relation) if not len(self.__args__) == len(args): @@ -417,7 +431,7 @@ def __init__(self, handle, relation, *args, **kwargs): obj = kwargs.get(attr, default) setattr(self, attr, obj) - def snapshot(self): + def snapshot(self) -> Dict[str, Any]: dct = super().snapshot() for attr in self.__attrs__(): obj = getattr(self, attr) @@ -432,7 +446,7 @@ def snapshot(self): return dct - def restore(self, snapshot) -> None: + def restore(self, snapshot: Any) -> None: super().restore(snapshot) for attr, obj in snapshot.items(): setattr(self, attr, obj) @@ -495,7 +509,7 @@ def __init__( """ super().__init__(charm, relation_name) - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: # created, joined or changed: if remote side has sent the required data: # notify listeners. if self.is_ready(event.relation): @@ -512,10 +526,10 @@ def _handle_relation(self, event): data.app.redirect_https or False, ) - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: self.on.data_removed.emit(event.relation, event.relation.app) # type: ignore - def wipe_ingress_data(self, relation: Relation): + def wipe_ingress_data(self, relation: Relation) -> None: """Clear ingress data from relation.""" assert self.unit.is_leader(), "only leaders can do this" try: @@ -539,7 +553,7 @@ def _get_requirer_units_data(self, relation: Relation) -> List["IngressRequirerU databag = relation.data[unit] try: data = IngressRequirerUnitData.load(databag) - out.append(data) + out.append(cast(IngressRequirerUnitData, data)) except pydantic.ValidationError: log.info(f"failed to validate remote unit data for {unit}") raise @@ -553,7 +567,7 @@ def _get_requirer_app_data(relation: Relation) -> "IngressRequirerAppData": raise NotReadyError(relation) databag = relation.data[app] - return IngressRequirerAppData.load(databag) + return cast(IngressRequirerAppData, IngressRequirerAppData.load(databag)) def get_data(self, relation: Relation) -> IngressRequirerData: """Fetch the remote (requirer) app and units' databags.""" @@ -566,7 +580,7 @@ def get_data(self, relation: Relation) -> IngressRequirerData: "failed to validate ingress requirer data: %s" % str(e) ) from e - def is_ready(self, relation: Optional[Relation] = None): + def is_ready(self, relation: Optional[Relation] = None) -> bool: """The Provider is ready if the requirer has sent valid data.""" if not relation: return any(map(self.is_ready, self.relations)) @@ -594,7 +608,7 @@ def _published_url(self, relation: Relation) -> Optional["IngressProviderAppData return IngressProviderAppData.load(databag) - def publish_url(self, relation: Relation, url: str): + def publish_url(self, relation: Relation, url: str) -> None: """Publish to the app databag the ingress url.""" ingress_url = {"url": url} try: @@ -604,8 +618,11 @@ def publish_url(self, relation: Relation, url: str): # If we cannot validate the url as valid, publish an empty databag and log the error. log.error(f"Failed to validate ingress url '{url}' - got ValidationError {e}") log.error( - "url was not published to ingress relation for {relation.app}. This error is likely due to an" - " error or misconfiguration of the charm calling this library." + ( + f"url was not published to ingress relation for {relation.app}." + f"This error is likely due to an error or misconfiguration of the" + "charm calling this library." + ) ) IngressProviderAppData(ingress=None).dump(relation.data[self.app]) # type: ignore @@ -630,7 +647,10 @@ def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: for ingress_relation in self.relations: if not ingress_relation.app: log.warning( - f"no app in relation {ingress_relation} when fetching proxied endpoints: skipping" + ( + f"no app in relation {ingress_relation} when fetching proxied endpoints:" + "skipping" + ) ) continue try: @@ -647,8 +667,7 @@ def proxied_endpoints(self) -> Dict[str, Dict[str, str]]: continue # Validation above means ingress cannot be None, but type checker doesn't know that. - ingress = ingress_data.ingress - ingress = cast(IngressProviderAppData, ingress) + ingress = cast(IngressProviderAppData, ingress_data.ingress) if PYDANTIC_IS_V1: results[ingress_relation.app.name] = ingress.dict() else: @@ -683,6 +702,7 @@ class IngressPerAppRequirer(_IngressPerAppBase): # used to prevent spurious urls to be sent out if the event we're currently # handling is a relation-broken one. _stored = StoredState() + _auto_data: Optional[Tuple[Optional[str], Optional[str], int]] def __init__( self, @@ -695,7 +715,8 @@ def __init__( strip_prefix: bool = False, redirect_https: bool = False, # fixme: this is horrible UX. - # shall we switch to manually calling provide_ingress_requirements with all args when ready? + # shall we switch to manually calling provide_ingress_requirements with all args when + # ready? scheme: Union[Callable[[], str], str] = lambda: "http", healthcheck_params: Optional[Dict[str, Any]] = None, ): @@ -715,19 +736,25 @@ def __init__( ip: Alternative addressing method other than host to be used by the ingress provider; if unspecified, the binding address from the Juju network API will be used. healthcheck_params: Optional dictionary containing health check - configuration parameters conforming to the IngressHealthCheck schema. The dictionary must include: + configuration parameters conforming to the IngressHealthCheck schema. + The dictionary must include: - "path" (str): The health check endpoint path (required). It may also include: - - "scheme" (Optional[str]): Replaces the server URL scheme for the health check endpoint. + - "scheme" (Optional[str]): Replaces the server URL scheme for the health check + endpoint. - "hostname" (Optional[str]): Hostname to be set in the health check request. - - "port" (Optional[int]): Replaces the server URL port for the health check endpoint. - - "interval" (str): Frequency of the health check calls (defaults to "30s" if omitted). - - "timeout" (str): Maximum duration for a health check request (defaults to "5s" if omitted). - If provided, "path" is required while "interval" and "timeout" will use Traefik's defaults when not specified. + - "port" (Optional[int]): Replaces the server URL port for the health check + endpoint. + - "interval" (str): Frequency of the health check calls + (defaults to "30s" if omitted). + - "timeout" (str): Maximum duration for a health check request + (defaults to "5s" if omitted). + If provided, "path" is required while "interval" and "timeout" will use Traefik's + defaults when not specified. strip_prefix: Configure Traefik to strip the path prefix. redirect_https: Redirect incoming requests to HTTPS. - scheme: Either a callable that returns the scheme to use when constructing the ingress URL, - or a string if the scheme is known and stable at charm initialization. + scheme: Either a callable that returns the scheme to use when constructing the ingress + URL, or a string if the scheme is known and stable at charm initialization. Request Args: port: the port of the service @@ -749,7 +776,7 @@ def __init__( else: self._auto_data = None - def _handle_relation(self, event): + def _handle_relation(self, event: RelationEvent) -> None: # created, joined or changed: if we have auto data: publish it self._publish_auto_data() if self.is_ready(): @@ -763,15 +790,15 @@ def _handle_relation(self, event): self._stored.current_url = new_url # type: ignore self.on.ready.emit(event.relation, new_url) # type: ignore - def _handle_relation_broken(self, event): + def _handle_relation_broken(self, event: RelationEvent) -> None: self._stored.current_url = None # type: ignore self.on.revoked.emit(relation=event.relation, app=event.relation.app) # type: ignore - def _handle_upgrade_or_leader(self, event): + def _handle_upgrade_or_leader(self, event: EventBase) -> None: """On upgrade/leadership change: ensure we publish the data we have.""" self._publish_auto_data() - def is_ready(self): + def is_ready(self) -> bool: """The Requirer is ready if the Provider has sent valid data.""" try: return bool(self._get_url_from_relation_data()) @@ -779,7 +806,7 @@ def is_ready(self): log.debug("Requirer not ready; validation error encountered: %s" % str(e)) return False - def _publish_auto_data(self): + def _publish_auto_data(self) -> None: if self._auto_data: host, ip, port = self._auto_data self.provide_ingress_requirements(host=host, ip=ip, port=port) @@ -791,7 +818,7 @@ def provide_ingress_requirements( host: Optional[str] = None, ip: Optional[str] = None, port: int, - ): + ) -> None: """Publishes the data that Traefik needs to provide ingress. Args: @@ -812,7 +839,7 @@ def _provide_ingress_requirements( ip: Optional[str], port: int, relation: Relation, - ): + ) -> None: if self.unit.is_leader(): self._publish_app_data(scheme, port, relation) @@ -823,7 +850,7 @@ def _publish_unit_data( host: Optional[str], ip: Optional[str], relation: Relation, - ): + ) -> None: if not host: host = socket.getfqdn() @@ -850,7 +877,7 @@ def _publish_app_data( scheme: Optional[str], port: int, relation: Relation, - ): + ) -> None: # assumes leadership! app_databag = relation.data[self.app] @@ -859,13 +886,14 @@ def _publish_app_data( scheme = self._get_scheme() try: - IngressRequirerAppData( # type: ignore # pyright does not like aliases + # Ignore pyright errors since pyright does not like aliases. + IngressRequirerAppData( # type: ignore model=self.model.name, name=self.app.name, scheme=scheme, port=port, - strip_prefix=self._strip_prefix, # type: ignore # pyright does not like aliases - redirect_https=self._redirect_https, # type: ignore # pyright does not like aliases + strip_prefix=self._strip_prefix, # type: ignore + redirect_https=self._redirect_https, # type: ignore healthcheck_params=( IngressHealthCheck(**self.healthcheck_params) if self.healthcheck_params @@ -878,7 +906,7 @@ def _publish_app_data( raise DataValidationError(msg) from e @property - def relation(self): + def relation(self) -> Optional[Relation]: """The established Relation instance, or None.""" return self.relations[0] if self.relations else None @@ -904,7 +932,7 @@ def _get_url_from_relation_data(self) -> Optional[str]: if not databag: # not ready yet return None - ingress = IngressProviderAppData.load(databag).ingress + ingress = cast(IngressProviderAppData, IngressProviderAppData.load(databag)).ingress if ingress is None: return None