blob: 78ec0e3479dbe547339f7de18efc2a93d8c4b9e8 [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 Garcia141d9352021-09-08 17:48:40 +020042 PodRestartPolicy,
David Garcia49379ce2021-02-24 13:48:22 +010043 PodSpecV3Builder,
44)
David Garciac753dc52021-03-17 15:28:47 +010045from opslib.osm.validator import ModelValidator, validator
David Garcia49379ce2021-02-24 13:48:22 +010046
47
sousaedub17e76b2021-01-26 12:58:25 +010048logger = logging.getLogger(__name__)
49
sousaedub17e76b2021-01-26 12:58:25 +010050
David Garcia49379ce2021-02-24 13:48:22 +010051class ConfigModel(ModelValidator):
sousaedu28dfe7e2021-06-30 15:03:28 +010052 log_level: str
53 port: int
54 admin_user: str
David Garcia49379ce2021-02-24 13:48:22 +010055 max_file_size: int
56 osm_dashboards: bool
57 site_url: Optional[str]
sousaedu3cc03162021-04-29 16:53:12 +020058 cluster_issuer: Optional[str]
David Garciad68e0b42021-06-28 16:50:42 +020059 ingress_class: Optional[str]
David Garcia49379ce2021-02-24 13:48:22 +010060 ingress_whitelist_source_range: Optional[str]
61 tls_secret_name: Optional[str]
sousaedu0dc25b32021-08-30 16:33:33 +010062 image_pull_policy: str
David Garcia49379ce2021-02-24 13:48:22 +010063
sousaedu28dfe7e2021-06-30 15:03:28 +010064 @validator("log_level")
65 def validate_log_level(cls, v):
66 allowed_values = ("debug", "info", "warn", "error", "critical")
67 if v not in allowed_values:
68 separator = '", "'
69 raise ValueError(
70 f'incorrect value. Allowed values are "{separator.join(allowed_values)}"'
71 )
72 return v
73
David Garcia49379ce2021-02-24 13:48:22 +010074 @validator("max_file_size")
75 def validate_max_file_size(cls, v):
76 if v < 0:
77 raise ValueError("value must be equal or greater than 0")
78 return v
79
80 @validator("site_url")
81 def validate_site_url(cls, v):
82 if v:
83 parsed = urlparse(v)
84 if not parsed.scheme.startswith("http"):
85 raise ValueError("value must start with http")
86 return v
87
88 @validator("ingress_whitelist_source_range")
89 def validate_ingress_whitelist_source_range(cls, v):
90 if v:
91 ip_network(v)
92 return v
sousaedub17e76b2021-01-26 12:58:25 +010093
sousaedu3ddbbd12021-08-24 19:57:24 +010094 @validator("image_pull_policy")
95 def validate_image_pull_policy(cls, v):
96 values = {
97 "always": "Always",
98 "ifnotpresent": "IfNotPresent",
99 "never": "Never",
100 }
101 v = v.lower()
102 if v not in values.keys():
103 raise ValueError("value must be always, ifnotpresent or never")
104 return values[v]
105
sousaedub17e76b2021-01-26 12:58:25 +0100106
David Garcia49379ce2021-02-24 13:48:22 +0100107class GrafanaCharm(CharmedOsmBase):
108 """GrafanaCharm Charm."""
sousaedub17e76b2021-01-26 12:58:25 +0100109
110 def __init__(self, *args) -> NoReturn:
David Garcia49379ce2021-02-24 13:48:22 +0100111 """Prometheus Charm constructor."""
sousaedu28dfe7e2021-06-30 15:03:28 +0100112 super().__init__(*args, oci_image="image", mysql_uri=True)
113 # Initialize relation objects
David Garcia49379ce2021-02-24 13:48:22 +0100114 self.prometheus_client = PrometheusClient(self, "prometheus")
sousaedu28dfe7e2021-06-30 15:03:28 +0100115 self.grafana_cluster = GrafanaCluster(self, "cluster")
116 self.mysql_client = MysqlClient(self, "db")
117 # Observe events
118 event_observer_mapping = {
119 self.on["prometheus"].relation_changed: self.configure_pod,
120 self.on["prometheus"].relation_broken: self.configure_pod,
121 self.on["db"].relation_changed: self.configure_pod,
122 self.on["db"].relation_broken: self.configure_pod,
123 }
124 for event, observer in event_observer_mapping.items():
125 self.framework.observe(event, observer)
sousaedub17e76b2021-01-26 12:58:25 +0100126
David Garcia49379ce2021-02-24 13:48:22 +0100127 def _build_dashboard_files(self, config: ConfigModel):
128 files_builder = FilesV3Builder()
129 files_builder.add_file(
130 "dashboard_osm.yaml",
David Garciad680be42021-08-17 11:03:55 +0200131 Path("templates/default_dashboards.yaml").read_text(),
David Garcia49379ce2021-02-24 13:48:22 +0100132 )
133 if config.osm_dashboards:
134 osm_dashboards_mapping = {
David Garciad680be42021-08-17 11:03:55 +0200135 "kafka_exporter_dashboard.json": "templates/kafka_exporter_dashboard.json",
136 "mongodb_exporter_dashboard.json": "templates/mongodb_exporter_dashboard.json",
137 "mysql_exporter_dashboard.json": "templates/mysql_exporter_dashboard.json",
138 "nodes_exporter_dashboard.json": "templates/nodes_exporter_dashboard.json",
139 "summary_dashboard.json": "templates/summary_dashboard.json",
David Garcia49379ce2021-02-24 13:48:22 +0100140 }
141 for file_name, path in osm_dashboards_mapping.items():
142 files_builder.add_file(file_name, Path(path).read_text())
143 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100144
David Garcia49379ce2021-02-24 13:48:22 +0100145 def _build_datasources_files(self):
146 files_builder = FilesV3Builder()
147 files_builder.add_file(
148 "datasource_prometheus.yaml",
David Garciad680be42021-08-17 11:03:55 +0200149 Template(Path("templates/default_datasources.yaml").read_text()).substitute(
David Garcia49379ce2021-02-24 13:48:22 +0100150 prometheus_host=self.prometheus_client.hostname,
151 prometheus_port=self.prometheus_client.port,
152 ),
153 )
154 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100155
sousaedu28dfe7e2021-06-30 15:03:28 +0100156 def _check_missing_dependencies(self, config: ConfigModel, external_db: bool):
David Garcia49379ce2021-02-24 13:48:22 +0100157 missing_relations = []
sousaedub17e76b2021-01-26 12:58:25 +0100158
David Garcia49379ce2021-02-24 13:48:22 +0100159 if self.prometheus_client.is_missing_data_in_app():
160 missing_relations.append("prometheus")
sousaedub17e76b2021-01-26 12:58:25 +0100161
sousaedu28dfe7e2021-06-30 15:03:28 +0100162 if not external_db and self.mysql_client.is_missing_data_in_unit():
163 missing_relations.append("db")
164
David Garcia49379ce2021-02-24 13:48:22 +0100165 if missing_relations:
166 raise RelationsMissing(missing_relations)
sousaedub17e76b2021-01-26 12:58:25 +0100167
sousaedu28dfe7e2021-06-30 15:03:28 +0100168 def build_pod_spec(self, image_info, **kwargs):
David Garcia49379ce2021-02-24 13:48:22 +0100169 # Validate config
170 config = ConfigModel(**dict(self.config))
sousaedu28dfe7e2021-06-30 15:03:28 +0100171 mysql_config = kwargs["mysql_config"]
172 if mysql_config.mysql_uri and not self.mysql_client.is_missing_data_in_unit():
173 raise Exception("Mysql data cannot be provided via config and relation")
174
David Garcia49379ce2021-02-24 13:48:22 +0100175 # Check relations
sousaedu28dfe7e2021-06-30 15:03:28 +0100176 external_db = True if mysql_config.mysql_uri else False
177 self._check_missing_dependencies(config, external_db)
178
179 # Get initial password
180 admin_initial_password = self.grafana_cluster.admin_initial_password
181 if not admin_initial_password:
182 admin_initial_password = _generate_random_password()
183 self.grafana_cluster.set_initial_password(admin_initial_password)
184
David Garcia49379ce2021-02-24 13:48:22 +0100185 # Create Builder for the PodSpec
186 pod_spec_builder = PodSpecV3Builder()
sousaedu28dfe7e2021-06-30 15:03:28 +0100187
David Garcia141d9352021-09-08 17:48:40 +0200188 # Add secrets to the pod
189 grafana_secret_name = f"{self.app.name}-admin-secret"
190 pod_spec_builder.add_secret(
191 grafana_secret_name,
192 {
193 "admin-password": admin_initial_password,
194 "mysql-url": mysql_config.mysql_uri or self.mysql_client.get_uri(),
195 },
196 )
197
David Garcia49379ce2021-02-24 13:48:22 +0100198 # Build Container
sousaedu3ddbbd12021-08-24 19:57:24 +0100199 container_builder = ContainerV3Builder(
200 self.app.name, image_info, config.image_pull_policy
201 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100202 container_builder.add_port(name=self.app.name, port=config.port)
David Garcia49379ce2021-02-24 13:48:22 +0100203 container_builder.add_http_readiness_probe(
204 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100205 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100206 initial_delay_seconds=10,
207 period_seconds=10,
208 timeout_seconds=5,
209 failure_threshold=3,
210 )
211 container_builder.add_http_liveness_probe(
212 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100213 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100214 initial_delay_seconds=60,
215 timeout_seconds=30,
216 failure_threshold=10,
217 )
218 container_builder.add_volume_config(
219 "dashboards",
220 "/etc/grafana/provisioning/dashboards/",
221 self._build_dashboard_files(config),
222 )
223 container_builder.add_volume_config(
224 "datasources",
225 "/etc/grafana/provisioning/datasources/",
226 self._build_datasources_files(),
227 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100228
229 container_builder.add_envs(
230 {
231 "GF_SERVER_HTTP_PORT": config.port,
232 "GF_LOG_LEVEL": config.log_level,
233 "GF_SECURITY_ADMIN_USER": config.admin_user,
David Garcia141d9352021-09-08 17:48:40 +0200234 }
235 )
236 container_builder.add_secret_envs(
237 secret_name=grafana_secret_name,
238 envs={
239 "GF_SECURITY_ADMIN_PASSWORD": "admin-password",
240 "GF_DATABASE_URL": "mysql-url",
sousaedu28dfe7e2021-06-30 15:03:28 +0100241 },
242 )
David Garcia49379ce2021-02-24 13:48:22 +0100243 container = container_builder.build()
David Garcia49379ce2021-02-24 13:48:22 +0100244 pod_spec_builder.add_container(container)
David Garcia141d9352021-09-08 17:48:40 +0200245
246 # Add Pod restart policy
247 restart_policy = PodRestartPolicy()
248 restart_policy.add_secrets(secret_names=(grafana_secret_name,))
249 pod_spec_builder.set_restart_policy(restart_policy)
250
David Garcia49379ce2021-02-24 13:48:22 +0100251 # Add ingress resources to pod spec if site url exists
252 if config.site_url:
253 parsed = urlparse(config.site_url)
254 annotations = {
255 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
256 str(config.max_file_size) + "m"
257 if config.max_file_size > 0
258 else config.max_file_size
David Garciad68e0b42021-06-28 16:50:42 +0200259 )
David Garcia49379ce2021-02-24 13:48:22 +0100260 }
David Garciad68e0b42021-06-28 16:50:42 +0200261 if config.ingress_class:
262 annotations["kubernetes.io/ingress.class"] = config.ingress_class
David Garcia49379ce2021-02-24 13:48:22 +0100263 ingress_resource_builder = IngressResourceV3Builder(
264 f"{self.app.name}-ingress", annotations
sousaedub17e76b2021-01-26 12:58:25 +0100265 )
sousaedub17e76b2021-01-26 12:58:25 +0100266
David Garcia49379ce2021-02-24 13:48:22 +0100267 if config.ingress_whitelist_source_range:
268 annotations[
269 "nginx.ingress.kubernetes.io/whitelist-source-range"
270 ] = config.ingress_whitelist_source_range
sousaedub17e76b2021-01-26 12:58:25 +0100271
sousaedu3cc03162021-04-29 16:53:12 +0200272 if config.cluster_issuer:
273 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
274
David Garcia49379ce2021-02-24 13:48:22 +0100275 if parsed.scheme == "https":
276 ingress_resource_builder.add_tls(
277 [parsed.hostname], config.tls_secret_name
278 )
279 else:
280 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
281
sousaedu28dfe7e2021-06-30 15:03:28 +0100282 ingress_resource_builder.add_rule(
283 parsed.hostname, self.app.name, config.port
284 )
David Garcia49379ce2021-02-24 13:48:22 +0100285 ingress_resource = ingress_resource_builder.build()
286 pod_spec_builder.add_ingress_resource(ingress_resource)
287 return pod_spec_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100288
289
sousaedu28dfe7e2021-06-30 15:03:28 +0100290def _generate_random_password():
291 return secrets.token_hex(16)
292
293
sousaedub17e76b2021-01-26 12:58:25 +0100294if __name__ == "__main__":
295 main(GrafanaCharm)