diff --git a/lib/charms/opensearch/v0/helper_charm.py b/lib/charms/opensearch/v0/helper_charm.py index 45434a5dd..bdf58269d 100644 --- a/lib/charms/opensearch/v0/helper_charm.py +++ b/lib/charms/opensearch/v0/helper_charm.py @@ -2,10 +2,12 @@ # See LICENSE file for licensing details. """Utility functions for charms related operations.""" +import json import logging import os import re import subprocess +from pathlib import Path from time import time_ns from types import SimpleNamespace from typing import TYPE_CHECKING, List, Union @@ -15,6 +17,7 @@ from charms.opensearch.v0.models import App, PeerClusterApp from charms.opensearch.v0.opensearch_exceptions import OpenSearchCmdError from charms.opensearch.v0.opensearch_internal_data import Scope +from data_platform_helpers.version_check import get_charm_revision from ops import CharmBase from ops.model import ActiveStatus, StatusBase, Unit @@ -228,3 +231,44 @@ def mask_sensitive_information(cmd: str) -> str: pattern = re.compile(r"(-tspass\s+|-kspass\s+|-storepass\s+|-new\s+|pass:)(\S+)") return re.sub(pattern, r"\1" + "xxx", cmd) + + +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") + if old_title: + 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) + else: + logger.warning( + "Dashboard %s does not have title and cannot be updated", dashboard_path.name + ) diff --git a/lib/charms/opensearch/v0/opensearch_base_charm.py b/lib/charms/opensearch/v0/opensearch_base_charm.py index 65a1402f2..6c5cfb988 100644 --- a/lib/charms/opensearch/v0/opensearch_base_charm.py +++ b/lib/charms/opensearch/v0/opensearch_base_charm.py @@ -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, self.on.set_password_action, self.on.secret_changed, self.on[PeerRelationName].relation_changed, diff --git a/poetry.lock b/poetry.lock index 73503e593..42a5a2e7e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "allure-pytest" @@ -610,6 +610,24 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "data-platform-helpers" +version = "0.1.4" +description = "" +optional = false +python-versions = "<4.0,>=3.10" +files = [ + {file = "data_platform_helpers-0.1.4-py3-none-any.whl", hash = "sha256:d2bd2b89198f21f7ba0229722e2c38106b7580866fe1702bc376ba768a52665a"}, + {file = "data_platform_helpers-0.1.4.tar.gz", hash = "sha256:d21a460882b7198a6e5f5129a3da432f90a8e219fc5e09852eccb436053de7f6"}, +] + +[package.dependencies] +ops = ">=2.15.0,<3.0.0" + +[package.extras] +all = ["pytest_operator (==0.36.0)"] +tests = ["pytest_operator (==0.36.0)"] + [[package]] name = "decorator" version = "5.1.1" @@ -2443,4 +2461,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c1d623796668b0cb433f13db66235a8b32334290c7838d17ba9fdd81b269e8e0" +content-hash = "3072c9f2694c03cdfe2dbfed5804217e8418c5269739b46a9cc0c375db5b2aae" diff --git a/pyproject.toml b/pyproject.toml index b0384743b..6a81088da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,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] diff --git a/src/charm.py b/src/charm.py index 66e572b44..80cc3e8f7 100755 --- a/src/charm.py +++ b/src/charm.py @@ -9,6 +9,7 @@ import ops from charms.opensearch.v0.constants_charm import InstallError, InstallProgress +from charms.opensearch.v0.helper_charm 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 @@ -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. diff --git a/tests/unit/lib/test_helper_charm.py b/tests/unit/lib/test_helper_charm.py index cc7c8daef..e7bdd3947 100644 --- a/tests/unit/lib/test_helper_charm.py +++ b/tests/unit/lib/test_helper_charm.py @@ -3,10 +3,18 @@ """Unit test for the helper_cluster library.""" +import json import unittest +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock, call, mock_open, patch from charms.opensearch.v0.constants_charm import PeerRelationName -from charms.opensearch.v0.helper_charm import Status, mask_sensitive_information +from charms.opensearch.v0.helper_charm import ( + Status, + _update_dashboard_title, + mask_sensitive_information, + update_grafana_dashboards_titles, +) from ops.model import BlockedStatus, MaintenanceStatus, WaitingStatus from ops.testing import Harness @@ -64,3 +72,120 @@ def test_mask_sensitive_information(self): actual_result = mask_sensitive_information(command_to_test) assert actual_result == expected_result + + +class TestCOSGrafanaDashboard(unittest.TestCase): + + @patch("charms.opensearch.v0.helper_charm.get_charm_revision", return_value=167) + @patch("charms.opensearch.v0.helper_charm.Path.iterdir") + @patch("charms.opensearch.v0.helper_charm._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_charm.get_charm_revision", return_value=167) + @patch("charms.opensearch.v0.helper_charm.logger") + @patch("charms.opensearch.v0.helper_charm.Path.iterdir") + @patch("charms.opensearch.v0.helper_charm._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("charms.opensearch.v0.helper_charm.logger") + @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_logger, + ): + + _update_dashboard_title("167", MagicMock()) + + mock_logger.warning.assert_called_once()