Skip to content
Snippets Groups Projects
Commit 28dfe7e1 authored by sousaedu's avatar sousaedu
Browse files

Adding HA support for Grafana charm


Change-Id: Icac0e15706e49cb387ac6686cb26337b98a5a319
Signed-off-by: default avatarDavid Garcia <david.garcia@canonical.com>
parent cb992769
No related branches found
No related tags found
No related merge requests found
......@@ -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://<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
......@@ -41,3 +41,9 @@ resources:
requires:
prometheus:
interface: prometheus
db:
interface: mysql
limit: 1
peers:
cluster:
interface: grafana-cluster
......@@ -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)
......@@ -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__":
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment