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 1 commit 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
44 changes: 44 additions & 0 deletions lib/charms/opensearch/v0/helper_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
)
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
22 changes: 20 additions & 2 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 @@ -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]
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_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
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
127 changes: 126 additions & 1 deletion tests/unit/lib/test_helper_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Loading