From 28dfe7e17f5381a41b96d0608672e86f74005cdb Mon Sep 17 00:00:00 2001 From: sousaedu Date: Wed, 30 Jun 2021 15:03:28 +0100 Subject: [PATCH] Adding HA support for Grafana charm Change-Id: Icac0e15706e49cb387ac6686cb26337b98a5a319 Signed-off-by: David Garcia --- installers/charm/grafana/config.yaml | 19 ++++ installers/charm/grafana/metadata.yaml | 6 ++ installers/charm/grafana/src/charm.py | 96 ++++++++++++++++---- installers/charm/grafana/tests/test_charm.py | 61 ++++++++++++- 4 files changed, 163 insertions(+), 19 deletions(-) diff --git a/installers/charm/grafana/config.yaml b/installers/charm/grafana/config.yaml index 632f2120..d2657867 100644 --- a/installers/charm/grafana/config.yaml +++ b/installers/charm/grafana/config.yaml @@ -63,3 +63,22 @@ options: ImagePullPolicy configuration for the pod. Possible values: always, ifnotpresent, never default: always + mysql_uri: + type: string + description: | + Mysql uri with the following format: + mysql://:@:/ + admin_user: + type: string + description: Admin user + default: admin + log_level: + type: string + description: | + Logging level for Grafana. Options are “debug”, “info”, + “warn”, “error”, and “critical”. + default: info + port: + description: The port grafana-k8s will be listening on + type: int + default: 3000 diff --git a/installers/charm/grafana/metadata.yaml b/installers/charm/grafana/metadata.yaml index c06a8514..2db64c20 100644 --- a/installers/charm/grafana/metadata.yaml +++ b/installers/charm/grafana/metadata.yaml @@ -41,3 +41,9 @@ resources: requires: prometheus: interface: prometheus + db: + interface: mysql + limit: 1 +peers: + cluster: + interface: grafana-cluster diff --git a/installers/charm/grafana/src/charm.py b/installers/charm/grafana/src/charm.py index 87776aa9..c482bbbc 100755 --- a/installers/charm/grafana/src/charm.py +++ b/installers/charm/grafana/src/charm.py @@ -25,12 +25,15 @@ from ipaddress import ip_network import logging from pathlib import Path +import secrets from string import Template from typing import NoReturn, Optional from urllib.parse import urlparse from ops.main import main from opslib.osm.charm import CharmedOsmBase, RelationsMissing +from opslib.osm.interfaces.grafana import GrafanaCluster +from opslib.osm.interfaces.mysql import MysqlClient from opslib.osm.interfaces.prometheus import PrometheusClient from opslib.osm.pod import ( ContainerV3Builder, @@ -43,10 +46,11 @@ from opslib.osm.validator import ModelValidator, validator logger = logging.getLogger(__name__) -PORT = 3000 - class ConfigModel(ModelValidator): + log_level: str + port: int + admin_user: str max_file_size: int osm_dashboards: bool site_url: Optional[str] @@ -56,6 +60,16 @@ class ConfigModel(ModelValidator): tls_secret_name: Optional[str] image_pull_policy: Optional[str] + @validator("log_level") + def validate_log_level(cls, v): + allowed_values = ("debug", "info", "warn", "error", "critical") + if v not in allowed_values: + separator = '", "' + raise ValueError( + f'incorrect value. Allowed values are "{separator.join(allowed_values)}"' + ) + return v + @validator("max_file_size") def validate_max_file_size(cls, v): if v < 0: @@ -94,15 +108,20 @@ class GrafanaCharm(CharmedOsmBase): def __init__(self, *args) -> NoReturn: """Prometheus Charm constructor.""" - super().__init__(*args, oci_image="image") - + super().__init__(*args, oci_image="image", mysql_uri=True) + # Initialize relation objects self.prometheus_client = PrometheusClient(self, "prometheus") - self.framework.observe( - self.on["prometheus"].relation_changed, self.configure_pod - ) - self.framework.observe( - self.on["prometheus"].relation_broken, self.configure_pod - ) + self.grafana_cluster = GrafanaCluster(self, "cluster") + self.mysql_client = MysqlClient(self, "db") + # Observe events + event_observer_mapping = { + self.on["prometheus"].relation_changed: self.configure_pod, + self.on["prometheus"].relation_broken: self.configure_pod, + self.on["db"].relation_changed: self.configure_pod, + self.on["db"].relation_broken: self.configure_pod, + } + for event, observer in event_observer_mapping.items(): + self.framework.observe(event, observer) def _build_dashboard_files(self, config: ConfigModel): files_builder = FilesV3Builder() @@ -133,30 +152,46 @@ class GrafanaCharm(CharmedOsmBase): ) return files_builder.build() - def _check_missing_dependencies(self): + def _check_missing_dependencies(self, config: ConfigModel, external_db: bool): missing_relations = [] if self.prometheus_client.is_missing_data_in_app(): missing_relations.append("prometheus") + if not external_db and self.mysql_client.is_missing_data_in_unit(): + missing_relations.append("db") + if missing_relations: raise RelationsMissing(missing_relations) - def build_pod_spec(self, image_info): + def build_pod_spec(self, image_info, **kwargs): # Validate config config = ConfigModel(**dict(self.config)) + mysql_config = kwargs["mysql_config"] + if mysql_config.mysql_uri and not self.mysql_client.is_missing_data_in_unit(): + raise Exception("Mysql data cannot be provided via config and relation") + # Check relations - self._check_missing_dependencies() + external_db = True if mysql_config.mysql_uri else False + self._check_missing_dependencies(config, external_db) + + # Get initial password + admin_initial_password = self.grafana_cluster.admin_initial_password + if not admin_initial_password: + admin_initial_password = _generate_random_password() + self.grafana_cluster.set_initial_password(admin_initial_password) + # Create Builder for the PodSpec pod_spec_builder = PodSpecV3Builder() + # Build Container container_builder = ContainerV3Builder( self.app.name, image_info, config.image_pull_policy ) - container_builder.add_port(name=self.app.name, port=PORT) + container_builder.add_port(name=self.app.name, port=config.port) container_builder.add_http_readiness_probe( "/api/health", - PORT, + config.port, initial_delay_seconds=10, period_seconds=10, timeout_seconds=5, @@ -164,7 +199,7 @@ class GrafanaCharm(CharmedOsmBase): ) container_builder.add_http_liveness_probe( "/api/health", - PORT, + config.port, initial_delay_seconds=60, timeout_seconds=30, failure_threshold=10, @@ -179,9 +214,30 @@ class GrafanaCharm(CharmedOsmBase): "/etc/grafana/provisioning/datasources/", self._build_datasources_files(), ) + + container_builder.add_envs( + { + "GF_SERVER_HTTP_PORT": config.port, + "GF_LOG_LEVEL": config.log_level, + "GF_SECURITY_ADMIN_USER": config.admin_user, + "GF_SECURITY_ADMIN_PASSWORD": { + "secret": {"name": "grafana-admin-secret", "key": "admin-password"} + }, + "GF_DATABASE_URL": { + "secret": {"name": "grafana-admin-secret", "key": "mysql-url"} + }, + }, + ) container = container_builder.build() # Add container to pod spec pod_spec_builder.add_container(container) + pod_spec_builder.add_secret( + "grafana-admin-secret", + { + "admin-password": admin_initial_password, + "mysql-url": mysql_config.mysql_uri or self.mysql_client.get_uri(), + }, + ) # Add ingress resources to pod spec if site url exists if config.site_url: parsed = urlparse(config.site_url) @@ -213,11 +269,17 @@ class GrafanaCharm(CharmedOsmBase): else: annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false" - ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT) + ingress_resource_builder.add_rule( + parsed.hostname, self.app.name, config.port + ) ingress_resource = ingress_resource_builder.build() pod_spec_builder.add_ingress_resource(ingress_resource) return pod_spec_builder.build() +def _generate_random_password(): + return secrets.token_hex(16) + + if __name__ == "__main__": main(GrafanaCharm) diff --git a/installers/charm/grafana/tests/test_charm.py b/installers/charm/grafana/tests/test_charm.py index 714730d1..3bfd69c7 100644 --- a/installers/charm/grafana/tests/test_charm.py +++ b/installers/charm/grafana/tests/test_charm.py @@ -23,6 +23,7 @@ import sys from typing import NoReturn import unittest +from unittest.mock import patch from charm import GrafanaCharm from ops.model import ActiveStatus, BlockedStatus @@ -69,10 +70,49 @@ class TestCharm(unittest.TestCase): # Assertions self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + @patch("opslib.osm.interfaces.grafana.GrafanaCluster.set_initial_password") + def test_with_db_relation_and_prometheus(self, _) -> NoReturn: + self.initialize_prometheus_relation() + self.initialize_mysql_relation() + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + + @patch("opslib.osm.interfaces.grafana.GrafanaCluster.set_initial_password") + def test_with_db_config_and_prometheus(self, _) -> NoReturn: + self.initialize_prometheus_relation() + self.initialize_mysql_config() + self.assertIsInstance(self.harness.charm.unit.status, ActiveStatus) + def test_with_prometheus( self, ) -> NoReturn: """Test to see if prometheus relation is updated.""" + self.initialize_prometheus_relation() + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + def test_with_db_config(self) -> NoReturn: + "Test with mysql config" + self.initialize_mysql_config() + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + @patch("opslib.osm.interfaces.grafana.GrafanaCluster.set_initial_password") + def test_with_db_relations(self, _) -> NoReturn: + "Test with relations" + self.initialize_mysql_relation() + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + def test_exception_db_relation_and_config( + self, + ) -> NoReturn: + "Test with relations and config. Must throw exception" + self.initialize_mysql_config() + self.initialize_mysql_relation() + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + def initialize_prometheus_relation(self): relation_id = self.harness.add_relation("prometheus", "prometheus") self.harness.add_relation_unit(relation_id, "prometheus/0") self.harness.update_relation_data( @@ -81,8 +121,25 @@ class TestCharm(unittest.TestCase): {"hostname": "prometheus", "port": 9090}, ) - # Verifying status - self.assertNotIsInstance(self.harness.charm.unit.status, BlockedStatus) + def initialize_mysql_config(self): + self.harness.update_config( + {"mysql_uri": "mysql://grafana:$grafanapw$@host:3606/db"} + ) + + def initialize_mysql_relation(self): + relation_id = self.harness.add_relation("db", "mysql") + self.harness.add_relation_unit(relation_id, "mysql/0") + self.harness.update_relation_data( + relation_id, + "mysql/0", + { + "host": "mysql", + "port": 3306, + "user": "mano", + "password": "manopw", + "root_password": "rootmanopw", + }, + ) if __name__ == "__main__": -- 2.17.1