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

Implement grafana_datasource_exchange #476

Merged
merged 5 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
23 changes: 20 additions & 3 deletions lib/charms/grafana_k8s/v0/grafana_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,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 = 22
LIBPATCH = 24

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -432,13 +432,22 @@ def update_source(self, source_url: Optional[str] = ""):
def get_source_uids(self) -> Dict[str, Dict[str, str]]:
"""Get the datasource UID(s) assigned by the remote end(s) to this datasource.

Returns a mapping from remote application names to unit names to datasource uids.
Returns a mapping from remote application UIDs to unit names to datasource uids.
"""
uids = {}
for rel in self._charm.model.relations.get(self._relation_name, []):
if not rel:
continue
uids[rel.app.name] = json.loads(rel.data[rel.app]["datasource_uids"])
app_databag = rel.data[rel.app]
grafana_uid = app_databag.get("grafana_uid")
if not grafana_uid:
logger.warning(
"remote end is using an old grafana_datasource interface: "
"`grafana_uid` field not found."
)
continue

uids[grafana_uid] = json.loads(app_databag.get("datasource_uids", "{}"))
return uids

def _set_sources_from_event(self, event: RelationJoinedEvent) -> None:
Expand Down Expand Up @@ -568,6 +577,14 @@ def _publish_source_uids(self, rel: Relation, uids: Dict[str, str]):

Assumes only leader unit will call this method
"""
unique_grafana_name = "juju_{}_{}_{}_{}".format(
self._charm.model.name,
self._charm.model.uuid,
self._charm.model.app.name,
self._charm.model.unit.name.split("/")[1], # type: ignore
)

rel.data[self._charm.app]["grafana_uid"] = unique_grafana_name
rel.data[self._charm.app]["datasource_uids"] = json.dumps(uids)

def _get_source_config(self, rel: Relation):
Expand Down
4 changes: 4 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ provides:
interface: prometheus_scrape
grafana-dashboard:
interface: grafana_dashboard
send-datasource:
interface: grafana_datasource_exchange
description: |
Integration to share with other COS components this charm's grafana datasources, and receive theirs.

requires:
alertmanager:
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
cosl>=0.0.12
cosl>=0.0.47
# pinned to 2.16 as 2.17 breaks our unittests
ops
kubernetes
Expand Down
41 changes: 41 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from charms.tempo_coordinator_k8s.v0.tracing import TracingEndpointRequirer, charm_tracing_config
from charms.traefik_k8s.v1.ingress_per_unit import IngressPerUnitRequirer
from cosl import JujuTopology
from cosl.interfaces.datasource_exchange import DatasourceDict, DatasourceExchange
from ops import CollectStatusEvent, StoredState
from ops.charm import CharmBase
from ops.main import main
Expand Down Expand Up @@ -231,6 +232,12 @@ def __init__(self, *args):
self.charm_tracing, self._ca_cert_path
)

self.datasource_exchange = DatasourceExchange(
self,
provider_endpoint="send-datasource",
requirer_endpoint=None,
)

self.framework.observe(
self.workload_tracing.on.endpoint_changed, # type: ignore
self._on_workload_tracing_endpoint_changed,
Expand All @@ -252,12 +259,29 @@ def __init__(self, *args):
self._loki_push_api_alert_rules_changed,
)
self.framework.observe(self.on.logging_relation_changed, self._on_logging_relation_changed)

self.framework.observe(
self.on.send_datasource_relation_changed, self._on_grafana_source_changed
michaeldmitry marked this conversation as resolved.
Show resolved Hide resolved
)
self.framework.observe(
self.on.send_datasource_relation_departed, self._on_grafana_source_changed
)
self.framework.observe(
self.on.grafana_source_relation_changed, self._on_grafana_source_changed
)
self.framework.observe(
self.on.grafana_source_relation_departed, self._on_grafana_source_changed
)

self.framework.observe(self.on.collect_unit_status, self._on_collect_unit_status)

##############################################
# CHARM HOOKS HANDLERS #
##############################################

def _on_grafana_source_changed(self, _):
self._update_datasource_exchange()

def _on_collect_unit_status(self, event: CollectStatusEvent):
# "Pull" statuses
# TODO refactor _configure to turn the "rules" status into a "pull" status.
Expand Down Expand Up @@ -871,6 +895,23 @@ def _tsdb_versions_migration_dates(self) -> List[Dict[str, str]]:
ret.append({"version": "v13", "date": tomorrow.strftime(date_format)})
return ret

def _update_datasource_exchange(self) -> None:
"""Update the grafana-datasource-exchange relations."""
if not self.unit.is_leader():
return

# we might have multiple grafana-source relations, this method collects them all and returns a mapping from
# the `grafana_uid` to the contents of the `datasource_uids` field
# for simplicity, we assume that we're sending the same data to different grafanas.
# read more in https://discourse.charmhub.io/t/tempo-ha-docs-correlating-traces-metrics-logs/16116
grafana_uids_to_units_to_uids = self.grafana_source_provider.get_source_uids()
raw_datasources: List[DatasourceDict] = []

for grafana_uid, ds_uids in grafana_uids_to_units_to_uids.items():
for _, ds_uid in ds_uids.items():
raw_datasources.append({"type": "loki", "uid": ds_uid, "grafana_uid": grafana_uid})
self.datasource_exchange.publish(datasources=raw_datasources)


if __name__ == "__main__":
main(LokiOperatorCharm)
2 changes: 2 additions & 0 deletions tests/integration/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,8 @@ async def deploy_tempo_cluster(ops_test: OpsTest):
status="active",
timeout=2000,
idle_period=30,
# TODO: remove when https://github.com/canonical/tempo-coordinator-k8s-operator/issues/90 is fixed
raise_on_error=False,
)


Expand Down
81 changes: 81 additions & 0 deletions tests/interface/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

import json
from contextlib import ExitStack
from unittest.mock import MagicMock, patch

import ops
import pytest
from charms.tempo_coordinator_k8s.v0.charm_tracing import charm_tracing_disabled
from interface_tester import InterfaceTester
from ops import ActiveStatus
from scenario.state import Container, Exec, Relation, State

from charm import LokiOperatorCharm


@pytest.fixture(autouse=True, scope="module")
def patch_all():
with ExitStack() as stack:
stack.enter_context(patch("lightkube.core.client.GenericSyncClient"))
stack.enter_context(
patch.multiple(
"charms.observability_libs.v0.kubernetes_compute_resources_patch.KubernetesComputeResourcesPatch",
_namespace="test-namespace",
_patch=lambda _: None,
is_ready=MagicMock(return_value=True),
get_status=lambda _: ActiveStatus(""),
)
)
stack.enter_context(charm_tracing_disabled())

yield


loki_container = Container(
name="loki",
can_connect=True,
execs={Exec(["update-ca-certificates", "--fresh"], return_code=0)},
layers={"loki": ops.pebble.Layer({"services": {"loki": {}}})},
service_statuses={"loki": ops.pebble.ServiceStatus.ACTIVE},
)

grafana_source_relation = Relation(
"grafana-source",
remote_app_data={
"datasource_uids": json.dumps({"loki/0": "01234"}),
"grafana_uid": "5678",
},
)

grafana_datasource_exchange_relation = Relation(
"send-datasource",
remote_app_data={
"datasources": json.dumps([{"type": "loki", "uid": "01234", "grafana_uid": "5678"}])
},
)


@pytest.fixture
def grafana_datasource_tester(interface_tester: InterfaceTester):
interface_tester.configure(
charm_type=LokiOperatorCharm,
state_template=State(
leader=True, containers=[loki_container], relations=[grafana_source_relation]
),
)
yield interface_tester


@pytest.fixture
def grafana_datasource_exchange_tester(interface_tester: InterfaceTester):
interface_tester.configure(
charm_type=LokiOperatorCharm,
state_template=State(
leader=True,
containers=[loki_container],
relations=[grafana_source_relation, grafana_datasource_exchange_relation],
),
)
yield interface_tester
13 changes: 13 additions & 0 deletions tests/interface/test_grafana_datasource_exchange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
from interface_tester import InterfaceTester


def test_grafana_datasource_exchange_v0_interface(
grafana_datasource_exchange_tester: InterfaceTester,
):
grafana_datasource_exchange_tester.configure(
interface_name="grafana_datasource_exchange",
interface_version=0,
)
grafana_datasource_exchange_tester.run()
11 changes: 11 additions & 0 deletions tests/interface/test_grafana_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.
from interface_tester import InterfaceTester


def test_grafana_datasource_v0_interface(grafana_datasource_tester: InterfaceTester):
grafana_datasource_tester.configure(
interface_name="grafana_datasource",
interface_version=0,
)
grafana_datasource_tester.run()
19 changes: 17 additions & 2 deletions tests/scenario/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from unittest.mock import PropertyMock, patch

import ops
import pytest
from charms.tempo_coordinator_k8s.v0.charm_tracing import charm_tracing_disabled
from ops.testing import Context
from scenario import Container, Exec

from charm import LokiOperatorCharm

Expand All @@ -11,7 +14,7 @@ def tautology(*_, **__) -> bool:


@pytest.fixture
def loki_charm():
def loki_charm(tmp_path):
with patch.multiple(
"charm.KubernetesComputeResourcesPatch",
_namespace=PropertyMock("test-namespace"),
Expand All @@ -20,9 +23,21 @@ def loki_charm():
):
with patch("socket.getfqdn", new=lambda *args: "fqdn"):
with patch("lightkube.core.client.GenericSyncClient"):
yield LokiOperatorCharm
with charm_tracing_disabled():
yield LokiOperatorCharm


@pytest.fixture
def context(loki_charm):
return Context(loki_charm)


@pytest.fixture(scope="function")
def loki_container():
return Container(
"loki",
can_connect=True,
execs={Exec(["update-ca-certificates", "--fresh"], return_code=0)},
layers={"loki": ops.pebble.Layer({"services": {"loki": {}}})},
service_statuses={"loki": ops.pebble.ServiceStatus.INACTIVE},
)
78 changes: 78 additions & 0 deletions tests/scenario/test_datasource_exchange.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

import json

import pytest
from cosl.interfaces.datasource_exchange import (
DatasourceExchange,
DSExchangeAppData,
GrafanaDatasource,
)
from scenario import Relation, State

from charm import LokiOperatorCharm

ds_tempo = [
{"type": "tempo", "uid": "3", "grafana_uid": "4"},
]

ds_mimir = [
{"type": "prometheus", "uid": "8", "grafana_uid": "9"},
]

mimir_dsx = Relation(
"send-datasource",
remote_app_data=DSExchangeAppData(datasources=json.dumps(ds_mimir)).dump(),
)
tempo_dsx = Relation(
"send-datasource",
remote_app_data=DSExchangeAppData(datasources=json.dumps(ds_tempo)).dump(),
)

ds = Relation(
"grafana-source",
remote_app_data={
"grafana_uid": "9",
"datasource_uids": json.dumps({"loki/0": "1234"}),
},
)


@pytest.mark.parametrize("event_type", ("changed", "created", "joined"))
@pytest.mark.parametrize("relation_to_observe", (ds, mimir_dsx, tempo_dsx))
def test_datasource_send(context, loki_container, relation_to_observe, event_type):

state_in = State(
relations=[
ds,
mimir_dsx,
tempo_dsx,
],
containers=[loki_container],
leader=True,
)

# WHEN we receive a datasource-related event
with context(
getattr(context.on, f"relation_{event_type}")(relation_to_observe), state_in
) as mgr:
charm: LokiOperatorCharm = mgr.charm
# THEN we can find all received datasource uids
dsx: DatasourceExchange = charm.datasource_exchange
received = dsx.received_datasources
assert received == (
GrafanaDatasource(type="tempo", uid="3", grafana_uid="4"),
GrafanaDatasource(type="prometheus", uid="8", grafana_uid="9"),
)
state_out = mgr.run()

# AND THEN we publish our own datasource information to mimir and tempo
published_dsx_mimir = state_out.get_relation(mimir_dsx.id).local_app_data
published_dsx_tempo = state_out.get_relation(tempo_dsx.id).local_app_data
assert published_dsx_tempo == published_dsx_mimir
assert json.loads(published_dsx_tempo["datasources"])[0] == {
"type": "loki",
"uid": "1234",
"grafana_uid": "9",
}
9 changes: 9 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,12 @@ deps =
minio
commands =
pytest -v --tb native --log-cli-level=INFO --color=yes -s {posargs} {toxinidir}/tests/integration

[testenv:interface]
description = Run interface tests
deps =
pytest
-r{toxinidir}/requirements.txt
pytest-interface-tester
commands =
pytest -v --tb native --log-cli-level=INFO -s {posargs} {[vars]tst_path}/interface
Loading