Adding HA support for Grafana charm 50/11050/6
authorsousaedu <eduardo.sousa@canonical.com>
Wed, 30 Jun 2021 14:03:28 +0000 (15:03 +0100)
committersousaedu <eduardo.sousa@canonical.com>
Thu, 2 Sep 2021 15:56:02 +0000 (17:56 +0200)
Change-Id: Icac0e15706e49cb387ac6686cb26337b98a5a319
Signed-off-by: David Garcia <david.garcia@canonical.com>
installers/charm/grafana/config.yaml
installers/charm/grafana/metadata.yaml
installers/charm/grafana/src/charm.py
installers/charm/grafana/tests/test_charm.py

index 632f212..d265786 100644 (file)
@@ -63,3 +63,22 @@ options:
       ImagePullPolicy configuration for the pod.
       Possible values: always, ifnotpresent, never
     default: always
       ImagePullPolicy configuration for the pod.
       Possible values: always, ifnotpresent, never
     default: always
+  mysql_uri:
+    type: string
+    description: |
+      Mysql uri with the following format:
+        mysql://<user>:<pass>@<host>:<port>/<database>
+  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
index c06a851..2db64c2 100644 (file)
@@ -41,3 +41,9 @@ resources:
 requires:
   prometheus:
     interface: prometheus
 requires:
   prometheus:
     interface: prometheus
+  db:
+    interface: mysql
+    limit: 1
+peers:
+  cluster:
+    interface: grafana-cluster
index 87776aa..c482bbb 100755 (executable)
 from ipaddress import ip_network
 import logging
 from pathlib import Path
 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 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,
 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__)
 
 
 logger = logging.getLogger(__name__)
 
-PORT = 3000
-
 
 class ConfigModel(ModelValidator):
 
 class ConfigModel(ModelValidator):
+    log_level: str
+    port: int
+    admin_user: str
     max_file_size: int
     osm_dashboards: bool
     site_url: Optional[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]
 
     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:
     @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."""
 
     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.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()
 
     def _build_dashboard_files(self, config: ConfigModel):
         files_builder = FilesV3Builder()
@@ -133,30 +152,46 @@ class GrafanaCharm(CharmedOsmBase):
         )
         return files_builder.build()
 
         )
         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")
 
         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)
 
         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))
         # 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
         # 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()
         # Create Builder for the PodSpec
         pod_spec_builder = PodSpecV3Builder()
+
         # Build Container
         container_builder = ContainerV3Builder(
             self.app.name, image_info, config.image_pull_policy
         )
         # 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",
         container_builder.add_http_readiness_probe(
             "/api/health",
-            PORT,
+            config.port,
             initial_delay_seconds=10,
             period_seconds=10,
             timeout_seconds=5,
             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",
         )
         container_builder.add_http_liveness_probe(
             "/api/health",
-            PORT,
+            config.port,
             initial_delay_seconds=60,
             timeout_seconds=30,
             failure_threshold=10,
             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(),
         )
             "/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)
         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)
         # 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"
 
             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()
 
 
             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)
 if __name__ == "__main__":
     main(GrafanaCharm)
index 714730d..3bfd69c 100644 (file)
@@ -23,6 +23,7 @@
 import sys
 from typing import NoReturn
 import unittest
 import sys
 from typing import NoReturn
 import unittest
+from unittest.mock import patch
 
 from charm import GrafanaCharm
 from ops.model import ActiveStatus, BlockedStatus
 
 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)
 
         # 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."""
     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(
         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},
         )
 
             {"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__":
 
 
 if __name__ == "__main__":