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 revision to the dashboard title of the Grafana #510

Open
wants to merge 4 commits into
base: 2/edge
Choose a base branch
from
Open
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
52 changes: 52 additions & 0 deletions lib/charms/opensearch/v0/helper_cos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Copyright 2025 Canonical Ltd.
# See LICENSE file for licensing details.
"""Utility functions for charms COS operations."""


import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING

from data_platform_helpers.version_check import get_charm_revision

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
from charms.opensearch.v0.opensearch_base_charm import OpenSearchBaseCharm


def update_grafana_dashboards_titles(charm: "OpenSearchBaseCharm") -> None:
"""Update the titles in the specified directory to include the charm revision."""
revision = get_charm_revision(charm.model.unit)
path = charm.charm_dir / "src/grafana_dashboards"

for dashboard_path in path.iterdir():
if dashboard_path.is_file() and dashboard_path.suffix == ".json":
try:
_update_dashboard_title(revision, dashboard_path)
except (json.JSONDecodeError, IOError) as e:
logger.error("Failed to process %s: %s", dashboard_path.name, str(e))
else:
logger.warning("%s is not a valid JSON file", dashboard_path.name)


def _update_dashboard_title(revision: str, dashboard_path: Path) -> None:
"""Update the title of a Grafana dashboard file to include the charm revision."""
with open(dashboard_path, "r") as file:
dashboard = json.load(file)

old_title = dashboard.get("title", "Charmed OpenSearch")
title_prefix = old_title.split(" - Rev")[0]
new_title = f"{old_title} - Rev {revision}"
dashboard["title"] = f"{title_prefix} - Rev {revision}"

logger.info(
"Changing the title of dashboard %s from %s to %s",
dashboard_path.name,
old_title,
new_title,
)

with open(dashboard_path, "w") as file:
json.dump(dashboard, file, indent=4)
1 change: 1 addition & 0 deletions lib/charms/opensearch/v0/opensearch_base_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ def __init__(self, *args, distro: Type[OpenSearchDistribution] = None):
metrics_endpoints=[],
scrape_configs=self._scrape_config,
refresh_events=[
self.on.config_changed,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Why is this one needed?

Copy link
Member Author

@gabrielcocenza gabrielcocenza Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The COSAgentProvider has self.on.config_changed by default if no custom events are passed.

We are passing custom events, so there is no refresh on COS for config-changed.
When you upgrade a charm, after that the config-change hook is triggered.

In summary, I'm re adding the default behavior and at the same time cover my need of refreshing COS relation after charm upgrade

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing...
I didn't add self.on_charm_upgrade because I was seeing a race condition. Talking with the COS team, the order when you add those handlers matters and COS Provider should in theory be added as a last handler.

To do that, we would need to move the COSAgentProvider to the last child class of the inheritance and I though that it would be simpler just add back the config-changed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we refresh on COS broken event as well? It is a slightly off topic question for this PR but how can we assure the dashboard is gone if we remove the COS relation from opensearch?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tested and as expected (at least for me), the dashboards are removed if the relation with grafana-agent is removed

self.on.set_password_action,
self.on.secret_changed,
self.on[PeerRelationName].relation_changed,
Expand Down
27 changes: 23 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pydantic = "^1.10.17, <2"
cryptography = "^43.0.0"
jsonschema = "^4.23.0"
poetry-core = "^1.9.0"
data-platform-helpers = "^0.1.4"


[tool.poetry.group.charm-libs.dependencies]
Expand Down
2 changes: 2 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import ops
from charms.opensearch.v0.constants_charm import InstallError, InstallProgress
from charms.opensearch.v0.helper_cos import update_grafana_dashboards_titles
from charms.opensearch.v0.models import PerformanceType
from charms.opensearch.v0.opensearch_base_charm import OpenSearchBaseCharm
from charms.opensearch.v0.opensearch_exceptions import OpenSearchInstallError
Expand Down Expand Up @@ -137,6 +138,7 @@ def _set_upgrade_status(self):
logger.debug(f"Set app status to {self.app.status}")

def _on_upgrade_charm(self, _):
update_grafana_dashboards_titles(self)
if not self.performance_profile.current:
# We are running (1) install or (2) an upgrade on instance that pre-dates profile
# First, we set this unit's effective profile -> 1G heap and no index templates.
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/lib/test_helper_cos.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Copyright 2024 Canonical Ltd.
# See LICENSE file for licensing details.

"""Unit test for the helper_cos library."""

import json
import unittest
from pathlib import Path
from unittest.mock import MagicMock, PropertyMock, call, mock_open, patch

from charms.opensearch.v0.helper_cos import (
_update_dashboard_title,
update_grafana_dashboards_titles,
)


class TestCOSGrafanaDashboard(unittest.TestCase):

@patch("charms.opensearch.v0.helper_cos.get_charm_revision", return_value=167)
@patch("charms.opensearch.v0.helper_cos.Path.iterdir")
@patch("charms.opensearch.v0.helper_cos._update_dashboard_title")
def test_update_grafana_dashboards_titles(self, mock_update_dashboard, mock_iterdir, _):
mock_charm = MagicMock()
mock_charm.model.unit = MagicMock()
type(mock_charm).charm_dir = PropertyMock(return_value=Path("/fake/charm/dir"))

mock_json_file_1 = MagicMock(spec=Path)
mock_json_file_1.is_file.return_value = True
mock_json_file_1.suffix = ".json"
mock_json_file_1.name = "dashboard1.json"

mock_non_json_file = MagicMock(spec=Path)
mock_non_json_file.is_file.return_value = True
mock_non_json_file.suffix = ".txt"
mock_non_json_file.name = "not_a_dashboard.txt"

mock_json_file_2 = MagicMock(spec=Path)
mock_json_file_2.is_file.return_value = True
mock_json_file_2.suffix = ".json"
mock_json_file_2.name = "dashboard2.json"

mock_iterdir.return_value = [mock_json_file_1, mock_non_json_file, mock_json_file_2]

update_grafana_dashboards_titles(mock_charm)

# non-json files are not called
mock_update_dashboard.assert_has_calls(
[
call(167, mock_json_file_1),
call(167, mock_json_file_2),
],
any_order=True,
)

self.assertEqual(mock_update_dashboard.call_count, 2)

@patch("charms.opensearch.v0.helper_cos.get_charm_revision", return_value=167)
@patch("charms.opensearch.v0.helper_cos.logger")
@patch("charms.opensearch.v0.helper_cos.Path.iterdir")
@patch("charms.opensearch.v0.helper_cos._update_dashboard_title")
def test_update_grafana_dashboards_titles_json_exception(
self, mock_update_dashboard, mock_iterdir, mock_logger, _
):
mock_charm = MagicMock()
mock_charm.model.unit = MagicMock()
type(mock_charm).charm_dir = PropertyMock(return_value=Path("/fake/charm/dir"))

mock_json_file_1 = MagicMock(spec=Path)
mock_json_file_1.is_file.return_value = True
mock_json_file_1.suffix = ".json"
mock_json_file_1.name = "dashboard1.json"

mock_iterdir.return_value = [mock_json_file_1]

mock_update_dashboard.side_effect = json.JSONDecodeError("Error", "Error", 0)

update_grafana_dashboards_titles(mock_charm)

mock_logger.error.assert_called_once()

@patch(
"builtins.open",
new_callable=mock_open,
read_data=json.dumps({"title": "Charmed OpenSearch"}),
)
@patch("json.dump")
def test_update_dashboard_title_no_prior_revision(self, mock_json_dump, mock_open_func):

_update_dashboard_title(167, MagicMock())

expected_updated_dashboard = {"title": "Charmed OpenSearch - Rev 167"}
mock_json_dump.assert_called_once_with(
expected_updated_dashboard, mock_open_func(), indent=4
)

@patch(
"builtins.open",
new_callable=mock_open,
read_data=json.dumps({"title": "Charmed OpenSearch - Rev 166"}),
)
@patch("json.dump")
def test_update_dashboard_title_prior_revision(
self,
mock_json_dump,
mock_open_func,
):

_update_dashboard_title("167", MagicMock())

expected_updated_dashboard = {"title": "Charmed OpenSearch - Rev 167"}
mock_json_dump.assert_called_once_with(
expected_updated_dashboard, mock_open_func(), indent=4
)

@patch(
"builtins.open",
new_callable=mock_open,
read_data=json.dumps({"my-content": "content"}),
)
@patch("json.dump")
def test_update_dashboard_title_json_no_title(
self,
mock_json_dump,
mock_open_func,
):

_update_dashboard_title("167", MagicMock())

expected_updated_dashboard = {
"title": "Charmed OpenSearch - Rev 167",
"my-content": "content",
}
mock_json_dump.assert_called_once_with(
expected_updated_dashboard, mock_open_func(), indent=4
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ _meta:

## Demo users

kibanaserver:
hash: $2b$12$VNfTvrDDZ5JjT9.BZDqRTOh6sZoUxdMmgLueB4X0H93E0/e9FinRC
reserved: false
description: Kibanaserver user
admin:
hash: $2b$12$Cts2k6vk.emW.E1mQN.B5u/5DfwKybaXFRREHfIwUs7RUHNp8h.c.
hash: $2b$12$3wL/WsFm7y0S2jyfiyHV4elgGEg2jeXrPFUmYdZGkj5aDF9hpCPFi
reserved: false
backend_roles:
- admin
opendistro_security_roles:
- security_rest_api_access
- all_access
description: Admin user
kibanaserver:
hash: $2b$12$0ZYW/CiICy1.7YXpRYDjeOLD75qnPAu4I3w5KXZmJvFp1rRiCm8qa
reserved: false
description: Kibanaserver user
Loading