Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a catalogue endpoint #93

Merged
merged 4 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 memberlist UI, providing visibility into Tempo's cluster members and their health status.


storage:
data:
Expand Down Expand Up @@ -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"
Expand Down
164 changes: 164 additions & 0 deletions lib/charms/catalogue_k8s/v1/catalogue.py
Original file line number Diff line number Diff line change
@@ -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
]
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ lightkube>=0.15.4
lightkube-models>=1.24.1.4
tenacity==8.2.3
pydantic>=2
cosl>=0.0.46
# FIXME: when cos-lib PR is merged
cosl@git+https://github.com/canonical/cos-lib@TAP-158

# crossplane is a package from nginxinc to interact with the Nginx config
crossplane
Expand Down
20 changes: 20 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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 #
##################
Expand Down
9 changes: 7 additions & 2 deletions tests/scenario/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
Loading