diff --git a/charmcraft.yaml b/charmcraft.yaml index 56428284..7e9695f8 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -56,8 +56,12 @@ requires: logging: interface: loki_push_api ingress: - interface: ingress + interface: traefik_route limit: 1 + description: | + Ingress integration for Tempo server and Tempo receiver endpoints, + so that cross-model workloads can send their traces to Tempo through the ingress. + Uses `traefik_route` to open ports on Traefik host for tracing ingesters. storage: data: diff --git a/lib/charms/tempo_k8s/v2/tracing.py b/lib/charms/tempo_k8s/v2/tracing.py index 3cf54b1c..8a125fe8 100644 --- a/lib/charms/tempo_k8s/v2/tracing.py +++ b/lib/charms/tempo_k8s/v2/tracing.py @@ -104,7 +104,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 = 3 +LIBPATCH = 4 PYDEPS = ["pydantic"] @@ -308,6 +308,9 @@ class TracingProviderAppData(DatabagModel): # noqa: D101 external_url: Optional[str] = None """Server url. If an ingress is present, it will be the ingress address.""" + internal_scheme: Optional[str] = None + """Scheme for internal communication. If it is present, it will be protocol accepted by the provider.""" + class TracingRequirerAppData(DatabagModel): # noqa: D101 """Application databag model for the tracing requirer.""" @@ -495,6 +498,7 @@ def __init__( host: str, external_url: Optional[str] = None, relation_name: str = DEFAULT_RELATION_NAME, + internal_scheme: Optional[Literal["http", "https"]] = "http", ): """Initialize. @@ -525,6 +529,7 @@ def __init__( self._host = host self._external_url = external_url self._relation_name = relation_name + self._internal_scheme = internal_scheme self.framework.observe( self._charm.on[relation_name].relation_joined, self._on_relation_event ) @@ -590,10 +595,11 @@ def publish_receivers(self, receivers: Sequence[RawReceiver]): try: TracingProviderAppData( host=self._host, - external_url=self._external_url, + external_url=f"http://{self._external_url}" if self._external_url else None, receivers=[ Receiver(port=port, protocol=protocol) for protocol, port in receivers ], + internal_scheme=self._internal_scheme, ).dump(relation.data[self._charm.app]) except ModelError as e: @@ -822,11 +828,13 @@ def _get_endpoint( receiver = receivers[0] # if there's an external_url argument (v2.5+), use that. Otherwise, we use the tempo local fqdn if app_data.external_url: - url = app_data.external_url + url = f"{app_data.external_url}:{receiver.port}" else: - # FIXME: if we don't get an external url but only a - # hostname, we don't know what scheme we need to be using. ASSUME HTTP - url = f"http://{app_data.host}:{receiver.port}" + if app_data.internal_scheme: + url = f"{app_data.internal_scheme}://{app_data.host}:{receiver.port}" + else: + # if we didn't receive a scheme (old provider), we assume HTTP is used + url = f"http://{app_data.host}:{receiver.port}" if receiver.protocol.endswith("grpc"): # TCP protocols don't want an http/https scheme prefix diff --git a/lib/charms/traefik_route_k8s/v0/traefik_route.py b/lib/charms/traefik_route_k8s/v0/traefik_route.py new file mode 100644 index 00000000..de2da555 --- /dev/null +++ b/lib/charms/traefik_route_k8s/v0/traefik_route.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +r"""# Interface Library for traefik_route. + +This library wraps relation endpoints for traefik_route. The requirer of this +relation is the traefik-route-k8s charm, or any charm capable of providing +Traefik configuration files. The provider is the traefik-k8s charm, or another +charm willing to consume Traefik configuration files. + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.traefik_route_k8s.v0.traefik_route +``` + +To use the library from the provider side (Traefik): + +```yaml +requires: + traefik_route: + interface: traefik_route + limit: 1 +``` + +```python +from charms.traefik_route_k8s.v0.traefik_route import TraefikRouteProvider + +class TraefikCharm(CharmBase): + def __init__(self, *args): + # ... + self.traefik_route = TraefikRouteProvider(self) + + self.framework.observe( + self.traefik_route.on.ready, self._handle_traefik_route_ready + ) + + def _handle_traefik_route_ready(self, event): + config: str = self.traefik_route.get_config(event.relation) # yaml + # use config to configure Traefik +``` + +To use the library from the requirer side (TraefikRoute): + +```yaml +requires: + traefik-route: + interface: traefik_route + limit: 1 + optional: false +``` + +```python +# ... +from charms.traefik_route_k8s.v0.traefik_route import TraefikRouteRequirer + +class TraefikRouteCharm(CharmBase): + def __init__(self, *args): + # ... + traefik_route = TraefikRouteRequirer( + self, self.model.relations.get("traefik-route"), + "traefik-route" + ) + if traefik_route.is_ready(): + traefik_route.submit_to_traefik( + config={'my': {'traefik': 'configuration'}} + ) + +``` +""" +import logging +from typing import Optional + +import yaml +from ops.charm import CharmBase, CharmEvents, RelationEvent +from ops.framework import EventSource, Object, StoredState +from ops.model import Relation + +# The unique Charmhub library identifier, never change it +LIBID = "fe2ac43a373949f2bf61383b9f35c83c" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 10 + +log = logging.getLogger(__name__) + + +class TraefikRouteException(RuntimeError): + """Base class for exceptions raised by TraefikRoute.""" + + +class UnauthorizedError(TraefikRouteException): + """Raised when the unit needs leadership to perform some action.""" + + +class TraefikRouteProviderReadyEvent(RelationEvent): + """Event emitted when Traefik is ready to provide ingress for a routed unit.""" + + +class TraefikRouteProviderDataRemovedEvent(RelationEvent): + """Event emitted when a routed ingress relation is removed.""" + + +class TraefikRouteRequirerReadyEvent(RelationEvent): + """Event emitted when a unit requesting ingress has provided all data Traefik needs.""" + + +class TraefikRouteRequirerEvents(CharmEvents): + """Container for TraefikRouteRequirer events.""" + + ready = EventSource(TraefikRouteRequirerReadyEvent) + + +class TraefikRouteProviderEvents(CharmEvents): + """Container for TraefikRouteProvider events.""" + + ready = EventSource(TraefikRouteProviderReadyEvent) # TODO rename to data_provided in v1 + data_removed = EventSource(TraefikRouteProviderDataRemovedEvent) + + +class TraefikRouteProvider(Object): + """Implementation of the provider of traefik_route. + + This will presumably be owned by a Traefik charm. + The main idea is that Traefik will observe the `ready` event and, upon + receiving it, will fetch the config from the TraefikRoute's application databag, + apply it, and update its own app databag to let Route know that the ingress + is there. + The TraefikRouteProvider provides api to do this easily. + """ + + on = TraefikRouteProviderEvents() # pyright: ignore + _stored = StoredState() + + def __init__( + self, + charm: CharmBase, + relation_name: str = "traefik-route", + external_host: str = "", + *, + scheme: str = "http", + ): + """Constructor for TraefikRouteProvider. + + Args: + charm: The charm that is instantiating the instance. + relation_name: The name of the relation relation_name to bind to + (defaults to "traefik-route"). + external_host: The external host. + scheme: The scheme. + """ + super().__init__(charm, relation_name) + self._stored.set_default(external_host=None, scheme=None) + + self._charm = charm + self._relation_name = relation_name + + if ( + self._stored.external_host != external_host # pyright: ignore + or self._stored.scheme != scheme # pyright: ignore + ): + # If traefik endpoint details changed, update + self.update_traefik_address(external_host=external_host, scheme=scheme) + + self.framework.observe( + self._charm.on[relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self._charm.on[relation_name].relation_broken, self._on_relation_broken + ) + + @property + def external_host(self) -> str: + """Return the external host set by Traefik, if any.""" + self._update_stored() + return self._stored.external_host or "" # type: ignore + + @property + def scheme(self) -> str: + """Return the scheme set by Traefik, if any.""" + self._update_stored() + return self._stored.scheme or "" # type: ignore + + @property + def relations(self): + """The list of Relation instances associated with this endpoint.""" + return list(self._charm.model.relations[self._relation_name]) + + def _update_stored(self) -> None: + """Ensure that the stored data is up-to-date. + + This is split out into a separate method since, in the case of multi-unit deployments, + removal of a `TraefikRouteRequirer` will not cause a `RelationEvent`, but the guard on + app data ensures that only the previous leader will know what it is. Separating it + allows for reuse both when the property is called and if the relation changes, so a + leader change where the new leader checks the property will do the right thing. + """ + if not self._charm.unit.is_leader(): + return + + for relation in self._charm.model.relations[self._relation_name]: + if not relation.app: + self._stored.external_host = "" + self._stored.scheme = "" + return + external_host = relation.data[relation.app].get("external_host", "") + self._stored.external_host = ( + external_host or self._stored.external_host # pyright: ignore + ) + scheme = relation.data[relation.app].get("scheme", "") + self._stored.scheme = scheme or self._stored.scheme # pyright: ignore + + def _on_relation_changed(self, event: RelationEvent): + if self.is_ready(event.relation): + # todo check data is valid here? + self.update_traefik_address() + self.on.ready.emit(event.relation) + + def _on_relation_broken(self, event: RelationEvent): + self.on.data_removed.emit(event.relation) + + def update_traefik_address( + self, *, external_host: Optional[str] = None, scheme: Optional[str] = None + ): + """Ensure that requirers know the external host for Traefik.""" + if not self._charm.unit.is_leader(): + return + + for relation in self._charm.model.relations[self._relation_name]: + relation.data[self._charm.app]["external_host"] = external_host or self.external_host + relation.data[self._charm.app]["scheme"] = scheme or self.scheme + + # We first attempt to write relation data (which may raise) and only then update stored + # state. + self._stored.external_host = external_host + self._stored.scheme = scheme + + def is_ready(self, relation: Relation) -> bool: + """Whether TraefikRoute is ready on this relation. + + Returns True when the remote app shared the config; False otherwise. + """ + if not relation.app or not relation.data[relation.app]: + return False + return "config" in relation.data[relation.app] + + def get_config(self, relation: Relation) -> Optional[str]: + """Renamed to ``get_dynamic_config``.""" + log.warning("``TraefikRouteProvider.get_config`` is deprecated. " + "Use ``TraefikRouteProvider.get_dynamic_config`` instead") + return self.get_dynamic_config(relation) + + def get_dynamic_config(self, relation: Relation) -> Optional[str]: + """Retrieve the dynamic config published by the remote application.""" + if not self.is_ready(relation): + return None + return relation.data[relation.app].get("config") + + def get_static_config(self, relation: Relation) -> Optional[str]: + """Retrieve the static config published by the remote application.""" + if not self.is_ready(relation): + return None + return relation.data[relation.app].get("static") + + +class TraefikRouteRequirer(Object): + """Wrapper for the requirer side of traefik-route. + + The traefik_route requirer will publish to the application databag an object like: + { + 'config': + 'static': # optional + } + + NB: TraefikRouteRequirer does no validation; it assumes that the + traefik-route-k8s charm will provide valid yaml-encoded config. + The TraefikRouteRequirer provides api to store this config in the + application databag. + """ + + on = TraefikRouteRequirerEvents() # pyright: ignore + _stored = StoredState() + + def __init__(self, charm: CharmBase, relation: Relation, relation_name: str = "traefik-route"): + super(TraefikRouteRequirer, self).__init__(charm, relation_name) + self._stored.set_default(external_host=None, scheme=None) + + self._charm = charm + self._relation = relation + + self.framework.observe( + self._charm.on[relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self._charm.on[relation_name].relation_broken, self._on_relation_broken + ) + + @property + def external_host(self) -> str: + """Return the external host set by Traefik, if any.""" + self._update_stored() + return self._stored.external_host or "" # type: ignore + + @property + def scheme(self) -> str: + """Return the scheme set by Traefik, if any.""" + self._update_stored() + return self._stored.scheme or "" # type: ignore + + def _update_stored(self) -> None: + """Ensure that the stored host is up-to-date. + + This is split out into a separate method since, in the case of multi-unit deployments, + removal of a `TraefikRouteRequirer` will not cause a `RelationEvent`, but the guard on + app data ensures that only the previous leader will know what it is. Separating it + allows for reuse both when the property is called and if the relation changes, so a + leader change where the new leader checks the property will do the right thing. + """ + if not self._charm.unit.is_leader(): + return + + if self._relation: + for relation in self._charm.model.relations[self._relation.name]: + if not relation.app: + self._stored.external_host = "" + self._stored.scheme = "" + return + external_host = relation.data[relation.app].get("external_host", "") + self._stored.external_host = ( + external_host or self._stored.external_host # pyright: ignore + ) + scheme = relation.data[relation.app].get("scheme", "") + self._stored.scheme = scheme or self._stored.scheme # pyright: ignore + + def _on_relation_changed(self, event: RelationEvent) -> None: + """Update StoredState with external_host and other information from Traefik.""" + self._update_stored() + if self._charm.unit.is_leader(): + self.on.ready.emit(event.relation) + + def _on_relation_broken(self, event: RelationEvent) -> None: + """On RelationBroken, clear the stored data if set and emit an event.""" + self._stored.external_host = "" + if self._charm.unit.is_leader(): + self.on.ready.emit(event.relation) + + def is_ready(self) -> bool: + """Is the TraefikRouteRequirer ready to submit data to Traefik?""" + return self._relation is not None + + def submit_to_traefik(self, config: dict, static: dict=None): + """Relay an ingress configuration data structure to traefik. + + This will publish to the traefik-route relation databag + a chunk of Traefik dynamic config that the traefik charm on the other end can pick + up and apply. + + Use ``static`` if you need to update traefik's **static** configuration. + Note that this will force traefik to restart to comply. + """ + if not self._charm.unit.is_leader(): + raise UnauthorizedError() + + app_databag = self._relation.data[self._charm.app] + + # Traefik thrives on yaml, feels pointless to talk json to Route + app_databag["config"] = yaml.safe_dump(config) + + if static: + app_databag["static"] = yaml.safe_dump(static) diff --git a/src/charm.py b/src/charm.py index ac944edf..b22748d0 100755 --- a/src/charm.py +++ b/src/charm.py @@ -22,10 +22,11 @@ RequestEvent, TracingEndpointProvider, ) -from charms.traefik_k8s.v2.ingress import IngressPerAppRequirer +from charms.traefik_route_k8s.v0.traefik_route import TraefikRouteRequirer from ops.charm import ( CharmBase, CollectStatusEvent, + HookEvent, PebbleNoticeEvent, RelationEvent, WorkloadEvent, @@ -83,16 +84,24 @@ def __init__(self, *args): # self._profiling = ProfilingEndpointProvider( # self, jobs=[{"static_configs": [{"targets": ["*:4080"]}]}] # ) - # TODO: - # ingress route provisioning a separate TCP ingress for each receiver if GRPC doesn't work directly - self._ingress = IngressPerAppRequirer( - self, port=self.tempo.tempo_server_port, strip_prefix=True + self._ingress = TraefikRouteRequirer(self, self.model.get_relation("ingress"), "ingress") # type: ignore + self._tracing = TracingEndpointProvider( + # TODO set internal_scheme based on whether TLS is enabled + self, + host=self.tempo.host, + external_url=self._ingress.external_host, + internal_scheme="http", ) - self._tracing = TracingEndpointProvider( - self, host=self.tempo.host, external_url=self._ingress.url + self.framework.observe( + self.on["ingress"].relation_created, self._on_ingress_relation_created + ) + self.framework.observe( + self.on["ingress"].relation_joined, self._on_ingress_relation_joined ) + self.framework.observe(self.on.leader_elected, self._on_leader_elected) + self.framework.observe(self._ingress.on.ready, self._on_ingress_ready) self.framework.observe(self.on.tempo_pebble_ready, self._on_tempo_pebble_ready) self.framework.observe( @@ -104,8 +113,6 @@ def __init__(self, *args): self.framework.observe(self.on.tracing_relation_joined, self._on_tracing_relation_joined) self.framework.observe(self.on.tracing_relation_changed, self._on_tracing_relation_changed) self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status) - self.framework.observe(self._ingress.on.ready, self._on_ingress_ready) - self.framework.observe(self._ingress.on.revoked, self._on_ingress_revoked) self.framework.observe(self.on.list_receivers_action, self._on_list_receivers_action) def _is_legacy_v1_relation(self, relation): @@ -121,6 +128,19 @@ def _is_legacy_v1_relation(self, relation): return True + def _configure_ingress(self, _) -> None: + """Make sure the traefik route and tracing relation data are up to date.""" + if not self.unit.is_leader(): + return + + if self._ingress.is_ready(): + self._ingress.submit_to_traefik( + self._ingress_config, static=self._static_ingress_config + ) + if self._ingress.external_host: + self._update_tracing_v1_relations() + self._update_tracing_v2_relations() + @property def legacy_v1_relations(self): """List of relations using the v1 legacy protocol.""" @@ -146,6 +166,16 @@ def _on_tracing_relation_changed(self, e: RelationEvent): if not self._tracing.is_v2(e.relation): self._publish_v1_data(e.relation) + def _on_ingress_relation_created(self, e: RelationEvent): + self._configure_ingress(e) + + def _on_ingress_relation_joined(self, e: RelationEvent): + self._configure_ingress(e) + + def _on_leader_elected(self, e: HookEvent): + # as traefik_route goes through app data, we need to take lead of traefik_route if our leader dies. + self._configure_ingress(e) + def _update_tracing_v1_relations(self): for relation in self.model.relations[self._tracing._relation_name]: if not self._tracing.is_v2(relation): @@ -315,9 +345,66 @@ def hostname(self) -> str: def _on_list_receivers_action(self, event: ops.ActionEvent): res = {} for receiver in self._requested_receivers(): - res[receiver.replace("_", "-")] = f"{self._ingress.url or self.tempo.url}/{receiver}" + res[ + receiver.replace("_", "-") + ] = f"{self._ingress.external_host or self.tempo.url}/{receiver}" event.set_results(res) + @property + def _static_ingress_config(self) -> dict: + entry_points = {} + for protocol, port in self.tempo.all_ports.items(): + sanitized_protocol = protocol.replace("_", "-") + entry_points[sanitized_protocol] = {"address": f":{port}"} + + return {"entryPoints": entry_points} + + @property + def _ingress_config(self) -> dict: + """Build a raw ingress configuration for Traefik.""" + tcp_routers = {} + tcp_services = {} + http_routers = {} + http_services = {} + for protocol, port in self.tempo.all_ports.items(): + sanitized_protocol = protocol.replace("_", "-") + if sanitized_protocol.endswith("grpc"): + # grpc handling + tcp_routers[ + f"juju-{self.model.name}-{self.model.app.name}-{sanitized_protocol}" + ] = { + "entryPoints": [sanitized_protocol], + "service": f"juju-{self.model.name}-{self.model.app.name}-service-{sanitized_protocol}", + # TODO better matcher + "rule": "ClientIP(`0.0.0.0/0`)", + } + tcp_services[ + f"juju-{self.model.name}-{self.model.app.name}-service-{sanitized_protocol}" + ] = {"loadBalancer": {"servers": [{"address": f"{self.hostname}:{port}"}]}} + else: + # it's a http protocol, so we use a http section of the dynamic configuration + http_routers[ + f"juju-{self.model.name}-{self.model.app.name}-{sanitized_protocol}" + ] = { + "entryPoints": [sanitized_protocol], + "service": f"juju-{self.model.name}-{self.model.app.name}-service-{sanitized_protocol}", + # TODO better matcher + "rule": "ClientIP(`0.0.0.0/0`)", + } + http_services[ + f"juju-{self.model.name}-{self.model.app.name}-service-{sanitized_protocol}" + ] = {"loadBalancer": {"servers": [{"url": f"http://{self.hostname}:{port}"}]}} + return { + "tcp": { + "routers": tcp_routers, + "services": tcp_services, + }, + "http": { + "routers": http_routers, + "services": http_services, + }, + } + if __name__ == "__main__": # pragma: nocover main(TempoCharm) diff --git a/src/tempo.py b/src/tempo.py index 6db1e6dd..37583af9 100644 --- a/src/tempo.py +++ b/src/tempo.py @@ -5,7 +5,6 @@ """Tempo workload configuration and client.""" import logging import socket -import time from subprocess import CalledProcessError, getoutput from typing import Dict, List, Optional, Sequence, Tuple @@ -128,7 +127,6 @@ def update_config(self, requested_receivers: Sequence[ReceiverProtocol]) -> bool ) def restart(self) -> bool: """Try to restart the tempo service.""" - # restarting tempo can cause errors such as: # Could not bind to :3200 - Address in use # probably because of some lag with releasing the port. We restart tempo 'too quickly' diff --git a/tests/scenario/test_ingressed_tracing.py b/tests/scenario/test_ingressed_tracing.py new file mode 100644 index 00000000..4e231811 --- /dev/null +++ b/tests/scenario/test_ingressed_tracing.py @@ -0,0 +1,100 @@ +import json +import socket +from unittest.mock import patch + +import pytest +import yaml +from charms.tempo_k8s.v1.charm_tracing import charm_tracing_disabled +from scenario import Container, Relation, State + + +@pytest.fixture +def base_state(): + return State(leader=True, containers=[Container("tempo", can_connect=False)]) + + +def test_external_url_present(context, base_state): + # WHEN ingress is related with external_host + tracing = Relation("tracing", remote_app_data={"receivers": "[]"}) + ingress = Relation("ingress", remote_app_data={"external_host": "1.2.3.4", "scheme": "http"}) + state = base_state.replace(relations=[tracing, ingress]) + + with charm_tracing_disabled(): + out = context.run(getattr(tracing, "created_event"), state) + + # THEN external_url is present in tracing relation databag + tracing_out = out.get_relations(tracing.endpoint)[0] + assert tracing_out.local_app_data == { + "receivers": '[{"protocol": "otlp_http", "port": 4318}]', + "host": json.dumps(socket.getfqdn()), + "external_url": '"http://1.2.3.4"', + "internal_scheme": '"http"', + } + + +@patch("socket.getfqdn", lambda: "1.2.3.4") +def test_ingress_relation_set_with_dynamic_config(context, base_state): + # WHEN ingress is related with external_host + ingress = Relation("ingress", remote_app_data={"external_host": "1.2.3.4", "scheme": "http"}) + state = base_state.replace(relations=[ingress]) + + out = context.run(getattr(ingress, "joined_event"), state) + + expected_rel_data = { + "tcp": { + "routers": { + f"juju-{state.model.name}-tempo-k8s-otlp-grpc": { + "entryPoints": ["otlp-grpc"], + "rule": "ClientIP(`0.0.0.0/0`)", + "service": f"juju-{state.model.name}-tempo-k8s-service-otlp-grpc", + } + }, + "services": { + f"juju-{state.model.name}-tempo-k8s-service-otlp-grpc": { + "loadBalancer": {"servers": [{"address": "1.2.3.4:4317"}]} + } + }, + }, + "http": { + "routers": { + f"juju-{state.model.name}-tempo-k8s-jaeger-thrift-http": { + "entryPoints": ["jaeger-thrift-http"], + "rule": "ClientIP(`0.0.0.0/0`)", + "service": f"juju-{state.model.name}-tempo-k8s-service-jaeger-thrift-http", + }, + f"juju-{state.model.name}-tempo-k8s-otlp-http": { + "entryPoints": ["otlp-http"], + "rule": "ClientIP(`0.0.0.0/0`)", + "service": f"juju-{state.model.name}-tempo-k8s-service-otlp-http", + }, + f"juju-{state.model.name}-tempo-k8s-tempo-http": { + "entryPoints": ["tempo-http"], + "rule": "ClientIP(`0.0.0.0/0`)", + "service": f"juju-{state.model.name}-tempo-k8s-service-tempo-http", + }, + f"juju-{state.model.name}-tempo-k8s-zipkin": { + "entryPoints": ["zipkin"], + "rule": "ClientIP(`0.0.0.0/0`)", + "service": f"juju-{state.model.name}-tempo-k8s-service-zipkin", + }, + }, + "services": { + f"juju-{state.model.name}-tempo-k8s-service-jaeger-thrift-http": { + "loadBalancer": {"servers": [{"url": "http://1.2.3.4:14268"}]} + }, + f"juju-{state.model.name}-tempo-k8s-service-otlp-http": { + "loadBalancer": {"servers": [{"url": "http://1.2.3.4:4318"}]} + }, + f"juju-{state.model.name}-tempo-k8s-service-tempo-http": { + "loadBalancer": {"servers": [{"url": "http://1.2.3.4:3200"}]} + }, + f"juju-{state.model.name}-tempo-k8s-service-zipkin": { + "loadBalancer": {"servers": [{"url": "http://1.2.3.4:9411"}]} + }, + }, + }, + } + + # THEN dynamic config is present in ingress relation + ingress_out = out.get_relations(ingress.endpoint)[0] + assert yaml.safe_load(ingress_out.local_app_data["config"]) == expected_rel_data diff --git a/tests/scenario/test_tracing_legacy.py b/tests/scenario/test_tracing_legacy.py index 367912a3..bf00a14f 100644 --- a/tests/scenario/test_tracing_legacy.py +++ b/tests/scenario/test_tracing_legacy.py @@ -67,6 +67,7 @@ def test_tracing_v2_endpoint_published(context, evt_name, base_state): assert tracing_out.local_app_data == { "receivers": '[{"protocol": "otlp_http", "port": 4318}]', "host": json.dumps(socket.getfqdn()), + "internal_scheme": '"http"', } diff --git a/tests/scenario/test_tracing_requirer.py b/tests/scenario/test_tracing_requirer.py index 0f55263a..5c6bf673 100644 --- a/tests/scenario/test_tracing_requirer.py +++ b/tests/scenario/test_tracing_requirer.py @@ -12,6 +12,8 @@ from ops import CharmBase, Framework, RelationBrokenEvent, RelationChangedEvent from scenario import Context, Relation, State +from tempo import Tempo + class MyCharm(CharmBase): def __init__(self, framework: Framework): @@ -61,6 +63,76 @@ def test_requirer_api(context): assert epchanged.host == host +def test_requirer_api_with_internal_scheme(context): + host = socket.getfqdn() + tracing = Relation( + "tracing", + remote_app_data={ + "receivers": '[{"protocol": "otlp_grpc", "port": 4317}, ' + '{"protocol": "otlp_http", "port": 4318}, ' + '{"protocol": "zipkin", "port": 9411}]', + "host": json.dumps(host), + "internal_scheme": '"https"', + }, + ) + state = State(leader=True, relations=[tracing]) + + with charm_tracing_disabled(): + with context.manager(tracing.changed_event, state) as mgr: + charm = mgr.charm + assert charm.tracing.get_endpoint("otlp_grpc") == f"{host}:4317" + assert charm.tracing.get_endpoint("otlp_http") == f"https://{host}:4318" + assert charm.tracing.get_endpoint("zipkin") == f"https://{host}:9411" + + rel = charm.model.get_relation("tracing") + assert charm.tracing.is_ready(rel) + + rchanged, epchanged = context.emitted_events + assert isinstance(epchanged, EndpointChangedEvent) + assert epchanged.host == host + assert epchanged.receivers[0].protocol == "otlp_grpc" + + +def test_ingressed_requirer_api(context): + # WHEN external_url is present in remote app databag + external_url = "http://1.2.3.4" + host = socket.getfqdn() + tracing = Relation( + "tracing", + remote_app_data={ + "receivers": '[{"protocol": "otlp_grpc", "port": 4317}, ' + '{"protocol": "otlp_http", "port": 4318}, ' + '{"protocol": "zipkin", "port": 9411}]', + "host": json.dumps(host), + "external_url": json.dumps(external_url), + }, + ) + state = State(leader=True, relations=[tracing]) + + # THEN get_endpoint uses external URL instead of the host + with charm_tracing_disabled(): + with context.manager(tracing.changed_event, state) as mgr: + charm = mgr.charm + assert ( + charm.tracing.get_endpoint("otlp_grpc") + == f"{external_url.split('://')[1]}:{Tempo.receiver_ports['otlp_grpc']}" + ) + for proto in ["otlp_http", "zipkin"]: + assert ( + charm.tracing.get_endpoint(proto) + == f"{external_url}:{Tempo.receiver_ports[proto]}" + ) + + rel = charm.model.get_relation("tracing") + assert charm.tracing.is_ready(rel) + + rchanged, epchanged = context.emitted_events + assert isinstance(epchanged, EndpointChangedEvent) + assert epchanged.host == host + assert epchanged.receivers[0].protocol == "otlp_grpc" + assert epchanged.external_url == external_url + + @pytest.mark.parametrize( "data", ( diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 100074aa..8015564b 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,47 +1,62 @@ -import pytest - -from tempo import Tempo - - -@pytest.mark.parametrize( - "protocols, expected_config", - ( - ( - ( - "otlp_grpc", - "otlp_http", - "zipkin", - "tempo", - "jaeger_http_thrift", - "jaeger_grpc", - "jaeger_thrift_http", - "jaeger_thrift_http", - ), - { - "jaeger": { - "protocols": { - "grpc": None, - "thrift_http": None, - } - }, - "zipkin": None, - "otlp": {"protocols": {"http": None, "grpc": None}}, - }, - ), - ( - ("otlp_http", "zipkin", "tempo", "jaeger_thrift_http"), - { - "jaeger": { - "protocols": { - "thrift_http": None, - } - }, - "zipkin": None, - "otlp": {"protocols": {"http": None}}, - }, - ), - ([], {}), - ), -) -def test_tempo_receivers_config(protocols, expected_config): - assert Tempo(None)._build_receivers_config(protocols) == expected_config +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging +import unittest +from unittest.mock import patch + +from charm import TempoCharm +from ops.model import ActiveStatus +from ops.testing import Harness + +CONTAINER_NAME = "tempo" + + +class TestTempoCharm(unittest.TestCase): + @patch("charm.KubernetesServicePatch", lambda x, y: None) + def setUp(self): + self.harness = Harness(TempoCharm) + self.harness.set_model_name("testmodel") + self.addCleanup(self.harness.cleanup) + self.harness.set_leader(True) + self.harness.begin_with_initial_hooks() + self.maxDiff = None # we're comparing big traefik configs in tests + + def test_tempo_pebble_ready(self): + service = self._container.get_service("tempo") + self.assertTrue(service.is_running()) + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) + + def test_entrypoints_are_generated_with_sanitized_names(self): + expected_entrypoints = { + "entryPoints": { + "tempo-http": {"address": ":3200"}, + "zipkin": {"address": ":9411"}, + "otlp-grpc": {"address": ":4317"}, + "otlp-http": {"address": ":4318"}, + "jaeger-thrift-http": {"address": ":14268"}, + } + } + self.assertEqual(self.harness.charm._static_ingress_config, expected_entrypoints) + + def test_tracing_relation_updates_protocols_as_requested(self): + self.harness.set_leader(True) + self.harness.container_pebble_ready("tempo") + + tracing_rel_id = self.harness.add_relation("tracing", "grafana") + self.harness.add_relation_unit(tracing_rel_id, "grafana/0") + self.harness.update_relation_data( + tracing_rel_id, "grafana", {"receivers": '["otlp_http"]'} + ) + + rel_data = self.harness.get_relation_data(tracing_rel_id, self.harness.charm.app.name) + logging.warning(rel_data) + self.assertEqual(rel_data["receivers"], '[{"protocol": "otlp_http", "port": 4318}]') + + @property + def _container(self): + return self.harness.model.unit.get_container(CONTAINER_NAME) + + @property + def _plan(self): + return self.harness.get_container_pebble_plan(CONTAINER_NAME) diff --git a/tests/unit/test_tempo.py b/tests/unit/test_tempo.py new file mode 100644 index 00000000..100074aa --- /dev/null +++ b/tests/unit/test_tempo.py @@ -0,0 +1,47 @@ +import pytest + +from tempo import Tempo + + +@pytest.mark.parametrize( + "protocols, expected_config", + ( + ( + ( + "otlp_grpc", + "otlp_http", + "zipkin", + "tempo", + "jaeger_http_thrift", + "jaeger_grpc", + "jaeger_thrift_http", + "jaeger_thrift_http", + ), + { + "jaeger": { + "protocols": { + "grpc": None, + "thrift_http": None, + } + }, + "zipkin": None, + "otlp": {"protocols": {"http": None, "grpc": None}}, + }, + ), + ( + ("otlp_http", "zipkin", "tempo", "jaeger_thrift_http"), + { + "jaeger": { + "protocols": { + "thrift_http": None, + } + }, + "zipkin": None, + "otlp": {"protocols": {"http": None}}, + }, + ), + ([], {}), + ), +) +def test_tempo_receivers_config(protocols, expected_config): + assert Tempo(None)._build_receivers_config(protocols) == expected_config