From b4630c78eb4fd754a119e0dd16d891c3d2ca25d0 Mon Sep 17 00:00:00 2001 From: Michael Dmitry <33381599+michaeldmitry@users.noreply.github.com> Date: Wed, 18 Dec 2024 12:45:36 +0200 Subject: [PATCH] Add a `catalogue` endpoint (#93) * add catalogue * fix scenario tests * add to tf module * pin cosl --- charmcraft.yaml | 9 +- lib/charms/catalogue_k8s/v1/catalogue.py | 164 +++++++++++++++++++++++ requirements.txt | 2 +- src/charm.py | 20 +++ terraform/outputs.tf | 2 + tests/scenario/conftest.py | 9 +- 6 files changed, 201 insertions(+), 5 deletions(-) create mode 100644 lib/charms/catalogue_k8s/v1/catalogue.py diff --git a/charmcraft.yaml b/charmcraft.yaml index 9a760d8..10a81fc 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -101,6 +101,11 @@ requires: interface: grafana_datasource_exchange description: | Integration to share with other COS components this charm's datasources, and receive theirs. + catalogue: + interface: catalogue + description: | + Integration to help users discover Tempo's UI, providing visibility into Tempo's cluster members and their health status. + storage: data: @@ -129,8 +134,8 @@ bases: parts: charm: # uncomment this if you add git+ dependencies in requirements.txt: - #build-packages: - # - "git" + # build-packages: + # - "git" charm-binary-python-packages: - "pydantic>=2" - "cryptography" diff --git a/lib/charms/catalogue_k8s/v1/catalogue.py b/lib/charms/catalogue_k8s/v1/catalogue.py new file mode 100644 index 0000000..7874a48 --- /dev/null +++ b/lib/charms/catalogue_k8s/v1/catalogue.py @@ -0,0 +1,164 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Charm for providing services catalogues to bundles or sets of charms.""" + +import ipaddress +import logging +import socket +from typing import Optional + +from ops.charm import CharmBase +from ops.framework import EventBase, EventSource, Object, ObjectEvents + +LIBID = "fa28b361293b46668bcd1f209ada6983" +LIBAPI = 1 +LIBPATCH = 0 + +DEFAULT_RELATION_NAME = "catalogue" + +logger = logging.getLogger(__name__) + + +class CatalogueItem: + """`CatalogueItem` represents an application entry sent to a catalogue. + + The icon is an iconify mdi string; see https://icon-sets.iconify.design/mdi. + """ + + def __init__(self, name: str, url: str, icon: str, description: str = ""): + self.name = name + self.url = url + self.icon = icon + self.description = description + + +class CatalogueConsumer(Object): + """`CatalogueConsumer` is used to send over a `CatalogueItem`.""" + + def __init__( + self, + charm, + relation_name: str = DEFAULT_RELATION_NAME, + item: Optional[CatalogueItem] = None, + ): + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + self._item = item + + events = self._charm.on[self._relation_name] + self.framework.observe(events.relation_joined, self._on_relation_changed) + self.framework.observe(events.relation_broken, self._on_relation_changed) + self.framework.observe(events.relation_changed, self._on_relation_changed) + self.framework.observe(events.relation_departed, self._on_relation_changed) + self.framework.observe(events.relation_created, self._on_relation_changed) + + def _on_relation_changed(self, _): + self._update_relation_data() + + def _update_relation_data(self): + if not self._charm.unit.is_leader(): + return + + if not self._item: + return + + for relation in self._charm.model.relations[self._relation_name]: + relation.data[self._charm.model.app]["name"] = self._item.name + relation.data[self._charm.model.app]["description"] = self._item.description + relation.data[self._charm.model.app]["url"] = self.unit_address(relation) + relation.data[self._charm.model.app]["icon"] = self._item.icon + + def update_item(self, item: CatalogueItem): + """Update the catalogue item.""" + self._item = item + self._update_relation_data() + + def unit_address(self, relation): + """The unit address of the consumer, on which it is reachable. + + Requires ingress to be connected for it to be routable. + """ + if self._item and self._item.url: + return self._item.url + + unit_ip = str(self._charm.model.get_binding(relation).network.bind_address) + if self._is_valid_unit_address(unit_ip): + return unit_ip + + return socket.getfqdn() + + def _is_valid_unit_address(self, address: str) -> bool: + """Validate a unit address. + + At present only IP address validation is supported, but + this may be extended to DNS addresses also, as needed. + + Args: + address: a string representing a unit address + """ + try: + _ = ipaddress.ip_address(address) + except ValueError: + return False + + return True + + +class CatalogueItemsChangedEvent(EventBase): + """Event emitted when the catalogue entries change.""" + + def __init__(self, handle, items): + super().__init__(handle) + self.items = items + + def snapshot(self): + """Save catalogue entries information.""" + return {"items": self.items} + + def restore(self, snapshot): + """Restore catalogue entries information.""" + self.items = snapshot["items"] + + +class CatalogueEvents(ObjectEvents): + """Events raised by `CatalogueConsumer`.""" + + items_changed = EventSource(CatalogueItemsChangedEvent) + + +class CatalogueProvider(Object): + """`CatalogueProvider` is the side of the relation that serves the actual service catalogue.""" + + on = CatalogueEvents() # pyright: ignore + + def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME): + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + events = self._charm.on[self._relation_name] + self.framework.observe(events.relation_changed, self._on_relation_changed) + self.framework.observe(events.relation_joined, self._on_relation_changed) + self.framework.observe(events.relation_departed, self._on_relation_changed) + self.framework.observe(events.relation_broken, self._on_relation_broken) + + def _on_relation_broken(self, event): + self.on.items_changed.emit(items=self.items) # pyright: ignore + + def _on_relation_changed(self, event): + self.on.items_changed.emit(items=self.items) # pyright: ignore + + @property + def items(self): + """A list of apps sent over relation data.""" + return [ + { + "name": relation.data[relation.app].get("name", ""), + "url": relation.data[relation.app].get("url", ""), + "icon": relation.data[relation.app].get("icon", ""), + "description": relation.data[relation.app].get("description", ""), + } + for relation in self._charm.model.relations[self._relation_name] + if relation.app and relation.units + ] diff --git a/requirements.txt b/requirements.txt index c03c4cb..37530e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ lightkube>=0.15.4 lightkube-models>=1.24.1.4 tenacity==8.2.3 pydantic>=2 -cosl>=0.0.46 +cosl>=0.0.48 # crossplane is a package from nginxinc to interact with the Nginx config crossplane diff --git a/src/charm.py b/src/charm.py index b495677..638e594 100755 --- a/src/charm.py +++ b/src/charm.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple, cast, get_args import ops +from charms.catalogue_k8s.v1.catalogue import CatalogueItem from charms.grafana_k8s.v0.grafana_source import GrafanaSourceProvider from charms.prometheus_k8s.v1.prometheus_remote_write import ( PrometheusRemoteWriteConsumer, @@ -113,6 +114,7 @@ def __init__(self, *args): "workload-tracing": "self-workload-tracing", "send-datasource": None, "receive-datasource": "receive-datasource", + "catalogue": "catalogue", }, nginx_config=NginxConfig(server_name=self.hostname).config, workers_config=self.tempo.config, @@ -121,6 +123,7 @@ def __init__(self, *args): remote_write_endpoints=self.remote_write_endpoints, # type: ignore # TODO: future Tempo releases would be using otlp_xx protocols instead. workload_tracing_protocols=["jaeger_thrift_http"], + catalogue_item=self._catalogue_item, ) # configure this tempo as a datasource in grafana @@ -234,6 +237,23 @@ def enabled_receivers(self) -> Set[str]: ) return enabled_receivers + @property + def _catalogue_item(self) -> CatalogueItem: + """A catalogue application entry for this Tempo instance.""" + return CatalogueItem( + # use app.name in case there are multiple Tempo applications deployed. + name=self.app.name, + icon="transit-connection-variant", + # Unlike Prometheus, Tempo doesn't have a sophisticated web UI. + # Instead, we'll show the current cluster members and their health status. + # ref: https://grafana.com/docs/tempo/latest/api_docs/ + url=f"{self._external_url}:3200/memberlist", + description=( + "Tempo is a distributed tracing backend by Grafana, supporting Jaeger, " + "Zipkin, and OpenTelemetry protocols." + ), + ) + ################## # EVENT HANDLERS # ################## diff --git a/terraform/outputs.tf b/terraform/outputs.tf index b07f1c2..7ae32f9 100644 --- a/terraform/outputs.tf +++ b/terraform/outputs.tf @@ -12,6 +12,8 @@ output "endpoints" { self_charm_tracing = "self-charm-tracing", self_workload_tracing = "self-workload-tracing", send-remote-write = "send-remote-write", + receive_datasource = "receive-datasource" + catalogue = "catalogue", # Provides grafana_dashboard = "grafana-dashboard", grafana_source = "grafana-source", diff --git a/tests/scenario/conftest.py b/tests/scenario/conftest.py index 838516a..6d47399 100644 --- a/tests/scenario/conftest.py +++ b/tests/scenario/conftest.py @@ -22,9 +22,14 @@ def patch_buffer_file_for_charm_tracing(tmp_path): @pytest.fixture(autouse=True, scope="session") def cleanup_prometheus_alert_rules(): - # some tests trigger the charm to generate prometheus alert rules file in ./src; clean it up + # some tests trigger the charm to generate prometheus alert rules file in src/prometheus_alert_rules/consolidated_rules; clean it up yield - src_path = Path(__file__).parent / "src" + src_path = ( + Path(__file__).parent.parent.parent + / "src" + / "prometheus_alert_rules" + / "consolidated_rules" + ) rmtree(src_path)