From 748a0fcdc1fd08be007f87630799bfc415ab5c13 Mon Sep 17 00:00:00 2001 From: Leon <82407168+sed-i@users.noreply.github.com> Date: Fri, 29 Sep 2023 10:56:23 -0400 Subject: [PATCH] Update ingress after cert is written (#534) --- .../{v0 => v1}/alertmanager_dispatch.py | 133 ++++++++++-------- lib/charms/tempo_k8s/v0/tracing.py | 32 +++-- src/charm.py | 18 +-- 3 files changed, 106 insertions(+), 77 deletions(-) rename lib/charms/alertmanager_k8s/{v0 => v1}/alertmanager_dispatch.py (77%) diff --git a/lib/charms/alertmanager_k8s/v0/alertmanager_dispatch.py b/lib/charms/alertmanager_k8s/v1/alertmanager_dispatch.py similarity index 77% rename from lib/charms/alertmanager_k8s/v0/alertmanager_dispatch.py rename to lib/charms/alertmanager_k8s/v1/alertmanager_dispatch.py index 233c3535..cee1efde 100644 --- a/lib/charms/alertmanager_k8s/v0/alertmanager_dispatch.py +++ b/lib/charms/alertmanager_k8s/v1/alertmanager_dispatch.py @@ -15,7 +15,7 @@ ```python # ... -from charms.alertmanager_k8s.v0.alertmanager_dispatch import AlertmanagerConsumer +from charms.alertmanager_k8s.v1.alertmanager_dispatch import AlertmanagerConsumer class SomeApplication(CharmBase): def __init__(self, *args): @@ -25,11 +25,11 @@ def __init__(self, *args): ``` """ import logging -import socket -from typing import Callable, List, Optional, Set +from typing import Dict, Optional, Set from urllib.parse import urlparse import ops +import pydantic from ops.charm import CharmBase, RelationEvent, RelationJoinedEvent, RelationRole from ops.framework import EventBase, EventSource, Object, ObjectEvents from ops.model import Relation @@ -38,11 +38,13 @@ def __init__(self, *args): LIBID = "37f1ca6f8fe84e3092ebbf6dc2885310" # Increment this major API version when introducing breaking changes -LIBAPI = 0 +LIBAPI = 1 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 8 +LIBPATCH = 1 + +PYDEPS = ["pydantic"] # Set to match metadata.yaml INTERFACE_NAME = "alertmanager_dispatch" @@ -50,6 +52,34 @@ def __init__(self, *args): logger = logging.getLogger(__name__) +class _ProviderSchemaV0(pydantic.BaseModel): + # Currently, the provider splits the URL and the consumer merges. That's why we switched to v1. + public_address: str + scheme: str = "http" + + +class _ProviderSchemaV1(pydantic.BaseModel): + url: str + + # The following are v0 fields that are continued to be populated for backwards compatibility. + # TODO: when we switch to pydantic 2+, use computed_field instead of the following fields, and + # also drop the __init__. + # https://docs.pydantic.dev/latest/api/fields/#pydantic.fields.computed_field + public_address: Optional[str] # v0 relic + scheme: Optional[str] # v0 relic + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + parsed = urlparse(kwargs["url"]) + port = ":" + str(parsed.port) if parsed.port else "" + public_address = f"{parsed.hostname}{port}{parsed.path}" + + # Derive v0 fields from v1 field + self.public_address = public_address + self.scheme = parsed.scheme + + class ClusterChanged(EventBase): """Event raised when an alertmanager cluster is changed. @@ -119,7 +149,7 @@ class AlertmanagerConsumer(RelationManagerBase): A typical example of importing this library might be ```python - from charms.alertmanager_k8s.v0.alertmanager_dispatch import AlertmanagerConsumer + from charms.alertmanager_k8s.v1.alertmanager_dispatch import AlertmanagerConsumer ``` In your charm's `__init__` method: @@ -150,7 +180,7 @@ class AlertmanagerConsumer(RelationManagerBase): on = AlertmanagerConsumerEvents() # pyright: ignore - def __init__(self, charm: CharmBase, relation_name: str = "alerting"): + def __init__(self, charm: CharmBase, *, relation_name: str = "alerting"): super().__init__(charm, relation_name, RelationRole.requires) self.framework.observe( @@ -195,33 +225,27 @@ def _on_relation_changed(self, event: ops.charm.RelationChangedEvent): # inform consumer about the change self.on.cluster_changed.emit() # pyright: ignore - def get_cluster_info(self) -> List[str]: - """Returns a list of addresses of all the alertmanager units.""" + def get_cluster_info(self) -> Set[str]: + """Returns a list of URLs of all alertmanager units.""" if not (relation := self.charm.model.get_relation(self.name)): - return [] - - alertmanagers: List[str] = [] - for unit in relation.units: - address = relation.data[unit].get("public_address") - if address: - alertmanagers.append(address) - return sorted(alertmanagers) - - def get_cluster_info_with_scheme(self) -> List[str]: - """Returns a list of URLs of all the alertmanager units.""" - # FIXME: in v1 of the lib: - # - use a dict {"url": ...} so it's extendable - # - change return value to Set[str] - if not (relation := self.charm.model.get_relation(self.name)): - return [] + return set() alertmanagers: Set[str] = set() for unit in relation.units: - address = relation.data[unit].get("public_address") - scheme = relation.data[unit].get("scheme", "http") - if address: - alertmanagers.add(f"{scheme}://{address}") - return sorted(alertmanagers) + if rel_data := relation.data[unit]: + try: # v1 + data = _ProviderSchemaV1(**rel_data) + except pydantic.ValidationError as ev1: + try: # v0 + data = _ProviderSchemaV0(**rel_data) + except pydantic.ValidationError as ev0: + logger.warning("Relation data failed validation for v1: %s", ev1) + logger.warning("Relation data failed validation for v0: %s", ev0) + else: + alertmanagers.add(f"{data.scheme}://{data.public_address}") + else: + alertmanagers.add(data.url) + return alertmanagers def _on_relation_departed(self, _): """This hook notifies the charm that there may have been changes to the cluster.""" @@ -248,47 +272,40 @@ class AlertmanagerProvider(RelationManagerBase): A typical example of importing this library might be ```python - from charms.alertmanager_k8s.v0.alertmanager_dispatch import AlertmanagerProvider + from charms.alertmanager_k8s.v1.alertmanager_dispatch import AlertmanagerProvider ``` In your charm's `__init__` method: ```python - self.alertmanager_provider = AlertmanagerProvider(self, self._relation_name, self._api_port) + self.alertmanager_provider = AlertmanagerProvider( + self, relation_name=self._relation_name, external_url=f"http://{socket.getfqdn()}:9093" + ) ``` Then inform consumers on any update to alertmanager cluster data via ```python - self.alertmanager_provider.update_relation_data() + self.alertmanager_provider.update(external_url=self.ingress.url) ``` This provider auto-registers relation events on behalf of the main Alertmanager charm. Arguments: - charm (CharmBase): consumer charm - relation_name (str): relation name (not interface name) - api_port (int): alertmanager server's api port; this is needed here to avoid accessing - charm constructs directly - - Attributes: - charm (CharmBase): the Alertmanager charm + charm: consumer charm + external_url: URL for this unit's workload API endpoint + relation_name: relation name (not interface name) """ def __init__( self, - charm, - relation_name: str = "alerting", - api_port: int = 9093, # TODO: breaking change: drop this arg + charm: CharmBase, *, - external_url: Optional[Callable] = None, # TODO: breaking change: make this mandatory + external_url: str, + relation_name: str = "alerting", ): - # TODO: breaking change: force keyword-only args from relation_name onwards super().__init__(charm, relation_name, RelationRole.provides) - - # We don't need to worry about the literal "http" here because the external_url arg is set - # by the charm. TODO: drop it after external_url becomes a mandatory arg. - self._external_url = external_url or (lambda: f"http://{socket.getfqdn()}:{api_port}") + self._external_url = external_url events = self.charm.on[self.name] @@ -302,9 +319,9 @@ def _on_relation_joined(self, event: RelationJoinedEvent): This is needed for consumers such as prometheus, which should be aware of all alertmanager instances. """ - self.update_relation_data(event) + self._update_relation_data(event) - def _generate_relation_data(self, relation: Relation): + def _generate_relation_data(self, relation: Relation) -> Dict[str, str]: """Helper function to generate relation data in the correct format. Addresses are without scheme. @@ -314,13 +331,10 @@ def _generate_relation_data(self, relation: Relation): # deduplicate so that the config file only has one entry, but ideally the # "alertmanagers.[].static_configs.targets" section in the prometheus config should list # all units. - parsed = urlparse(self._external_url()) - return { - "public_address": f"{parsed.hostname}:{parsed.port or 80}{parsed.path}", - "scheme": parsed.scheme, - } + data = _ProviderSchemaV1(url=self._external_url) + return data.dict() - def update_relation_data(self, event: Optional[RelationEvent] = None): + def _update_relation_data(self, event: Optional[RelationEvent] = None): """Helper function for updating relation data bags. This function can be used in two different ways: @@ -346,3 +360,8 @@ def update_relation_data(self, event: Optional[RelationEvent] = None): event.relation.data[self.charm.unit].update( self._generate_relation_data(event.relation) ) + + def update(self, *, external_url: str): + """Update data pertaining to this relation manager (similar args to __init__).""" + self._external_url = external_url + self._update_relation_data() diff --git a/lib/charms/tempo_k8s/v0/tracing.py b/lib/charms/tempo_k8s/v0/tracing.py index 488297e8..cb5dac87 100644 --- a/lib/charms/tempo_k8s/v0/tracing.py +++ b/lib/charms/tempo_k8s/v0/tracing.py @@ -61,7 +61,17 @@ def __init__(self, *args): """ # noqa: W505 import json import logging -from typing import TYPE_CHECKING, List, Literal, MutableMapping, Optional, Tuple, cast +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + MutableMapping, + Optional, + Tuple, + cast, +) import pydantic from ops.charm import ( @@ -83,7 +93,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 = 6 +LIBPATCH = 7 PYDEPS = ["pydantic<2.0"] @@ -457,7 +467,7 @@ def relations(self) -> List[Relation]: return self._charm.model.relations[self._relation_name] @property - def _relation(self) -> Relation: + def _relation(self) -> Optional[Relation]: """If this wraps a single endpoint, the relation bound to it, if any.""" if not self._is_single_endpoint: objname = type(self).__name__ @@ -474,7 +484,7 @@ def is_ready(self, relation: Optional[Relation] = None): """Is this endpoint ready?""" relation = relation or self._relation if not relation: - logger.error(f"no relation on {self._relation_name}: tracing not ready") + logger.debug(f"no relation on {self._relation_name !r}: tracing not ready") return False if relation.data is None: logger.error(f"relation data is None for {relation}") @@ -502,7 +512,7 @@ def _on_tracing_relation_changed(self, event): def _on_tracing_relation_broken(self, event: RelationBrokenEvent): """Notify the providers that the endpoint is broken.""" relation = event.relation - self.on.endpoint_removed.emit(relation) + self.on.endpoint_removed.emit(relation) # type: ignore def get_all_endpoints( self, relation: Optional[Relation] = None @@ -512,7 +522,7 @@ def get_all_endpoints( return return TracingProviderAppData.load(relation.data[relation.app]) # type: ignore - def _get_ingester(self, relation: Relation, protocol: IngesterProtocol): + def _get_ingester(self, relation: Optional[Relation], protocol: IngesterProtocol): ep = self.get_all_endpoints(relation) if not ep: return None @@ -539,12 +549,10 @@ def tempo_endpoint(self, relation: Optional[Relation] = None) -> Optional[str]: """Ingester endpoint for the ``tempo`` protocol.""" return self._get_ingester(relation or self._relation, protocol="tempo") - @property - def jaeger_http_thrift_endpoint(self) -> Optional[str]: + def jaeger_http_thrift_endpoint(self, relation: Optional[Relation] = None) -> Optional[str]: """Ingester endpoint for the ``jaeger_http_thrift`` protocol.""" - return self._get_ingester("jaeger_http_thrift") + return self._get_ingester(relation or self._relation, "jaeger_http_thrift") - @property - def jaeger_grpc_endpoint(self) -> Optional[str]: + def jaeger_grpc_endpoint(self, relation: Optional[Relation] = None) -> Optional[str]: """Ingester endpoint for the ``jaeger_grpc`` protocol.""" - return self._get_ingester("jaeger_grpc") + return self._get_ingester(relation or self._relation, "jaeger_grpc") diff --git a/src/charm.py b/src/charm.py index 810c5347..5f89c6c4 100755 --- a/src/charm.py +++ b/src/charm.py @@ -14,7 +14,7 @@ from urllib.parse import urlparse import yaml -from charms.alertmanager_k8s.v0.alertmanager_dispatch import AlertmanagerConsumer +from charms.alertmanager_k8s.v1.alertmanager_dispatch import AlertmanagerConsumer from charms.catalogue_k8s.v0.catalogue import CatalogueConsumer, CatalogueItem from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.grafana_k8s.v0.grafana_source import GrafanaSourceProvider @@ -386,10 +386,7 @@ def _on_k8s_patch_failed(self, event: K8sResourcePatchFailedEvent): self.unit.status = BlockedStatus(cast(str, event.message)) def _on_server_cert_changed(self, _): - self.grafana_source_provider.update_source(self.external_url) - self.ingress.provide_ingress_requirements( - scheme=urlparse(self.internal_url).scheme, port=self._port - ) + self._update_cert() self._configure(_) def _is_cert_available(self) -> bool: @@ -492,9 +489,14 @@ def _configure(self, _): self.unit.status = MaintenanceStatus("Configuring Prometheus") return - if self._is_cert_available() and not self.container.exists(CERT_PATH): + if self._is_cert_available() and not self._is_tls_ready(): self._update_cert() + self.grafana_source_provider.update_source(self.external_url) + self.ingress.provide_ingress_requirements( + scheme=urlparse(self.internal_url).scheme, port=self._port + ) + try: # Need to reload if config or alerts changed. # (Both functions need to run so cannot use the short-circuiting `or`.) @@ -860,13 +862,13 @@ def _alerting_config(self) -> dict: Returns: a dictionary consisting of the alerting configuration for Prometheus. """ - alertmanagers = self.alertmanager_consumer.get_cluster_info_with_scheme() + alertmanagers = self.alertmanager_consumer.get_cluster_info() if not alertmanagers: logger.debug("No alertmanagers available") return {} alerting_config: Dict[str, list] = PrometheusConfig.render_alertmanager_static_configs( - alertmanagers + list(alertmanagers) ) return alerting_config