blob: c482bbbc457ca452b99a380fbcac789640f9f47c [file] [log] [blame]
sousaedub17e76b2021-01-26 12:58:25 +01001#!/usr/bin/env python3
2# Copyright 2021 Canonical Ltd.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15#
16# For those usages not covered by the Apache License, Version 2.0 please
17# contact: legal@canonical.com
18#
19# To get in touch with the maintainers, please contact:
20# osm-charmers@lists.launchpad.net
21##
22
David Garcia49379ce2021-02-24 13:48:22 +010023# pylint: disable=E0213
24
David Garcia49379ce2021-02-24 13:48:22 +010025from ipaddress import ip_network
David Garciac753dc52021-03-17 15:28:47 +010026import logging
27from pathlib import Path
sousaedu28dfe7e2021-06-30 15:03:28 +010028import secrets
David Garciac753dc52021-03-17 15:28:47 +010029from string import Template
30from typing import NoReturn, Optional
31from urllib.parse import urlparse
sousaedub17e76b2021-01-26 12:58:25 +010032
sousaedub17e76b2021-01-26 12:58:25 +010033from ops.main import main
David Garcia49379ce2021-02-24 13:48:22 +010034from opslib.osm.charm import CharmedOsmBase, RelationsMissing
sousaedu28dfe7e2021-06-30 15:03:28 +010035from opslib.osm.interfaces.grafana import GrafanaCluster
36from opslib.osm.interfaces.mysql import MysqlClient
David Garciac753dc52021-03-17 15:28:47 +010037from opslib.osm.interfaces.prometheus import PrometheusClient
David Garcia49379ce2021-02-24 13:48:22 +010038from opslib.osm.pod import (
David Garcia49379ce2021-02-24 13:48:22 +010039 ContainerV3Builder,
David Garciac753dc52021-03-17 15:28:47 +010040 FilesV3Builder,
41 IngressResourceV3Builder,
David Garcia49379ce2021-02-24 13:48:22 +010042 PodSpecV3Builder,
43)
David Garciac753dc52021-03-17 15:28:47 +010044from opslib.osm.validator import ModelValidator, validator
David Garcia49379ce2021-02-24 13:48:22 +010045
46
sousaedub17e76b2021-01-26 12:58:25 +010047logger = logging.getLogger(__name__)
48
sousaedub17e76b2021-01-26 12:58:25 +010049
David Garcia49379ce2021-02-24 13:48:22 +010050class ConfigModel(ModelValidator):
sousaedu28dfe7e2021-06-30 15:03:28 +010051 log_level: str
52 port: int
53 admin_user: str
David Garcia49379ce2021-02-24 13:48:22 +010054 max_file_size: int
55 osm_dashboards: bool
56 site_url: Optional[str]
sousaedu3cc03162021-04-29 16:53:12 +020057 cluster_issuer: Optional[str]
David Garciad68e0b42021-06-28 16:50:42 +020058 ingress_class: Optional[str]
David Garcia49379ce2021-02-24 13:48:22 +010059 ingress_whitelist_source_range: Optional[str]
60 tls_secret_name: Optional[str]
sousaedu3ddbbd12021-08-24 19:57:24 +010061 image_pull_policy: Optional[str]
David Garcia49379ce2021-02-24 13:48:22 +010062
sousaedu28dfe7e2021-06-30 15:03:28 +010063 @validator("log_level")
64 def validate_log_level(cls, v):
65 allowed_values = ("debug", "info", "warn", "error", "critical")
66 if v not in allowed_values:
67 separator = '", "'
68 raise ValueError(
69 f'incorrect value. Allowed values are "{separator.join(allowed_values)}"'
70 )
71 return v
72
David Garcia49379ce2021-02-24 13:48:22 +010073 @validator("max_file_size")
74 def validate_max_file_size(cls, v):
75 if v < 0:
76 raise ValueError("value must be equal or greater than 0")
77 return v
78
79 @validator("site_url")
80 def validate_site_url(cls, v):
81 if v:
82 parsed = urlparse(v)
83 if not parsed.scheme.startswith("http"):
84 raise ValueError("value must start with http")
85 return v
86
87 @validator("ingress_whitelist_source_range")
88 def validate_ingress_whitelist_source_range(cls, v):
89 if v:
90 ip_network(v)
91 return v
sousaedub17e76b2021-01-26 12:58:25 +010092
sousaedu3ddbbd12021-08-24 19:57:24 +010093 @validator("image_pull_policy")
94 def validate_image_pull_policy(cls, v):
95 values = {
96 "always": "Always",
97 "ifnotpresent": "IfNotPresent",
98 "never": "Never",
99 }
100 v = v.lower()
101 if v not in values.keys():
102 raise ValueError("value must be always, ifnotpresent or never")
103 return values[v]
104
sousaedub17e76b2021-01-26 12:58:25 +0100105
David Garcia49379ce2021-02-24 13:48:22 +0100106class GrafanaCharm(CharmedOsmBase):
107 """GrafanaCharm Charm."""
sousaedub17e76b2021-01-26 12:58:25 +0100108
109 def __init__(self, *args) -> NoReturn:
David Garcia49379ce2021-02-24 13:48:22 +0100110 """Prometheus Charm constructor."""
sousaedu28dfe7e2021-06-30 15:03:28 +0100111 super().__init__(*args, oci_image="image", mysql_uri=True)
112 # Initialize relation objects
David Garcia49379ce2021-02-24 13:48:22 +0100113 self.prometheus_client = PrometheusClient(self, "prometheus")
sousaedu28dfe7e2021-06-30 15:03:28 +0100114 self.grafana_cluster = GrafanaCluster(self, "cluster")
115 self.mysql_client = MysqlClient(self, "db")
116 # Observe events
117 event_observer_mapping = {
118 self.on["prometheus"].relation_changed: self.configure_pod,
119 self.on["prometheus"].relation_broken: self.configure_pod,
120 self.on["db"].relation_changed: self.configure_pod,
121 self.on["db"].relation_broken: self.configure_pod,
122 }
123 for event, observer in event_observer_mapping.items():
124 self.framework.observe(event, observer)
sousaedub17e76b2021-01-26 12:58:25 +0100125
David Garcia49379ce2021-02-24 13:48:22 +0100126 def _build_dashboard_files(self, config: ConfigModel):
127 files_builder = FilesV3Builder()
128 files_builder.add_file(
129 "dashboard_osm.yaml",
David Garciad680be42021-08-17 11:03:55 +0200130 Path("templates/default_dashboards.yaml").read_text(),
David Garcia49379ce2021-02-24 13:48:22 +0100131 )
132 if config.osm_dashboards:
133 osm_dashboards_mapping = {
David Garciad680be42021-08-17 11:03:55 +0200134 "kafka_exporter_dashboard.json": "templates/kafka_exporter_dashboard.json",
135 "mongodb_exporter_dashboard.json": "templates/mongodb_exporter_dashboard.json",
136 "mysql_exporter_dashboard.json": "templates/mysql_exporter_dashboard.json",
137 "nodes_exporter_dashboard.json": "templates/nodes_exporter_dashboard.json",
138 "summary_dashboard.json": "templates/summary_dashboard.json",
David Garcia49379ce2021-02-24 13:48:22 +0100139 }
140 for file_name, path in osm_dashboards_mapping.items():
141 files_builder.add_file(file_name, Path(path).read_text())
142 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100143
David Garcia49379ce2021-02-24 13:48:22 +0100144 def _build_datasources_files(self):
145 files_builder = FilesV3Builder()
146 files_builder.add_file(
147 "datasource_prometheus.yaml",
David Garciad680be42021-08-17 11:03:55 +0200148 Template(Path("templates/default_datasources.yaml").read_text()).substitute(
David Garcia49379ce2021-02-24 13:48:22 +0100149 prometheus_host=self.prometheus_client.hostname,
150 prometheus_port=self.prometheus_client.port,
151 ),
152 )
153 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100154
sousaedu28dfe7e2021-06-30 15:03:28 +0100155 def _check_missing_dependencies(self, config: ConfigModel, external_db: bool):
David Garcia49379ce2021-02-24 13:48:22 +0100156 missing_relations = []
sousaedub17e76b2021-01-26 12:58:25 +0100157
David Garcia49379ce2021-02-24 13:48:22 +0100158 if self.prometheus_client.is_missing_data_in_app():
159 missing_relations.append("prometheus")
sousaedub17e76b2021-01-26 12:58:25 +0100160
sousaedu28dfe7e2021-06-30 15:03:28 +0100161 if not external_db and self.mysql_client.is_missing_data_in_unit():
162 missing_relations.append("db")
163
David Garcia49379ce2021-02-24 13:48:22 +0100164 if missing_relations:
165 raise RelationsMissing(missing_relations)
sousaedub17e76b2021-01-26 12:58:25 +0100166
sousaedu28dfe7e2021-06-30 15:03:28 +0100167 def build_pod_spec(self, image_info, **kwargs):
David Garcia49379ce2021-02-24 13:48:22 +0100168 # Validate config
169 config = ConfigModel(**dict(self.config))
sousaedu28dfe7e2021-06-30 15:03:28 +0100170 mysql_config = kwargs["mysql_config"]
171 if mysql_config.mysql_uri and not self.mysql_client.is_missing_data_in_unit():
172 raise Exception("Mysql data cannot be provided via config and relation")
173
David Garcia49379ce2021-02-24 13:48:22 +0100174 # Check relations
sousaedu28dfe7e2021-06-30 15:03:28 +0100175 external_db = True if mysql_config.mysql_uri else False
176 self._check_missing_dependencies(config, external_db)
177
178 # Get initial password
179 admin_initial_password = self.grafana_cluster.admin_initial_password
180 if not admin_initial_password:
181 admin_initial_password = _generate_random_password()
182 self.grafana_cluster.set_initial_password(admin_initial_password)
183
David Garcia49379ce2021-02-24 13:48:22 +0100184 # Create Builder for the PodSpec
185 pod_spec_builder = PodSpecV3Builder()
sousaedu28dfe7e2021-06-30 15:03:28 +0100186
David Garcia49379ce2021-02-24 13:48:22 +0100187 # Build Container
sousaedu3ddbbd12021-08-24 19:57:24 +0100188 container_builder = ContainerV3Builder(
189 self.app.name, image_info, config.image_pull_policy
190 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100191 container_builder.add_port(name=self.app.name, port=config.port)
David Garcia49379ce2021-02-24 13:48:22 +0100192 container_builder.add_http_readiness_probe(
193 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100194 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100195 initial_delay_seconds=10,
196 period_seconds=10,
197 timeout_seconds=5,
198 failure_threshold=3,
199 )
200 container_builder.add_http_liveness_probe(
201 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100202 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100203 initial_delay_seconds=60,
204 timeout_seconds=30,
205 failure_threshold=10,
206 )
207 container_builder.add_volume_config(
208 "dashboards",
209 "/etc/grafana/provisioning/dashboards/",
210 self._build_dashboard_files(config),
211 )
212 container_builder.add_volume_config(
213 "datasources",
214 "/etc/grafana/provisioning/datasources/",
215 self._build_datasources_files(),
216 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100217
218 container_builder.add_envs(
219 {
220 "GF_SERVER_HTTP_PORT": config.port,
221 "GF_LOG_LEVEL": config.log_level,
222 "GF_SECURITY_ADMIN_USER": config.admin_user,
223 "GF_SECURITY_ADMIN_PASSWORD": {
224 "secret": {"name": "grafana-admin-secret", "key": "admin-password"}
225 },
226 "GF_DATABASE_URL": {
227 "secret": {"name": "grafana-admin-secret", "key": "mysql-url"}
228 },
229 },
230 )
David Garcia49379ce2021-02-24 13:48:22 +0100231 container = container_builder.build()
232 # Add container to pod spec
233 pod_spec_builder.add_container(container)
sousaedu28dfe7e2021-06-30 15:03:28 +0100234 pod_spec_builder.add_secret(
235 "grafana-admin-secret",
236 {
237 "admin-password": admin_initial_password,
238 "mysql-url": mysql_config.mysql_uri or self.mysql_client.get_uri(),
239 },
240 )
David Garcia49379ce2021-02-24 13:48:22 +0100241 # Add ingress resources to pod spec if site url exists
242 if config.site_url:
243 parsed = urlparse(config.site_url)
244 annotations = {
245 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
246 str(config.max_file_size) + "m"
247 if config.max_file_size > 0
248 else config.max_file_size
David Garciad68e0b42021-06-28 16:50:42 +0200249 )
David Garcia49379ce2021-02-24 13:48:22 +0100250 }
David Garciad68e0b42021-06-28 16:50:42 +0200251 if config.ingress_class:
252 annotations["kubernetes.io/ingress.class"] = config.ingress_class
David Garcia49379ce2021-02-24 13:48:22 +0100253 ingress_resource_builder = IngressResourceV3Builder(
254 f"{self.app.name}-ingress", annotations
sousaedub17e76b2021-01-26 12:58:25 +0100255 )
sousaedub17e76b2021-01-26 12:58:25 +0100256
David Garcia49379ce2021-02-24 13:48:22 +0100257 if config.ingress_whitelist_source_range:
258 annotations[
259 "nginx.ingress.kubernetes.io/whitelist-source-range"
260 ] = config.ingress_whitelist_source_range
sousaedub17e76b2021-01-26 12:58:25 +0100261
sousaedu3cc03162021-04-29 16:53:12 +0200262 if config.cluster_issuer:
263 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
264
David Garcia49379ce2021-02-24 13:48:22 +0100265 if parsed.scheme == "https":
266 ingress_resource_builder.add_tls(
267 [parsed.hostname], config.tls_secret_name
268 )
269 else:
270 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
271
sousaedu28dfe7e2021-06-30 15:03:28 +0100272 ingress_resource_builder.add_rule(
273 parsed.hostname, self.app.name, config.port
274 )
David Garcia49379ce2021-02-24 13:48:22 +0100275 ingress_resource = ingress_resource_builder.build()
276 pod_spec_builder.add_ingress_resource(ingress_resource)
277 return pod_spec_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100278
279
sousaedu28dfe7e2021-06-30 15:03:28 +0100280def _generate_random_password():
281 return secrets.token_hex(16)
282
283
sousaedub17e76b2021-01-26 12:58:25 +0100284if __name__ == "__main__":
285 main(GrafanaCharm)