blob: 28d28ae33e135ac2b4c54a7556d74092f5cd1bfd [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
sousaedu540d9372021-09-29 01:53:30 +010063 security_context: bool
David Garcia49379ce2021-02-24 13:48:22 +010064
sousaedu28dfe7e2021-06-30 15:03:28 +010065 @validator("log_level")
66 def validate_log_level(cls, v):
67 allowed_values = ("debug", "info", "warn", "error", "critical")
68 if v not in allowed_values:
69 separator = '", "'
70 raise ValueError(
71 f'incorrect value. Allowed values are "{separator.join(allowed_values)}"'
72 )
73 return v
74
David Garcia49379ce2021-02-24 13:48:22 +010075 @validator("max_file_size")
76 def validate_max_file_size(cls, v):
77 if v < 0:
78 raise ValueError("value must be equal or greater than 0")
79 return v
80
81 @validator("site_url")
82 def validate_site_url(cls, v):
83 if v:
84 parsed = urlparse(v)
85 if not parsed.scheme.startswith("http"):
86 raise ValueError("value must start with http")
87 return v
88
89 @validator("ingress_whitelist_source_range")
90 def validate_ingress_whitelist_source_range(cls, v):
91 if v:
92 ip_network(v)
93 return v
sousaedub17e76b2021-01-26 12:58:25 +010094
sousaedu3ddbbd12021-08-24 19:57:24 +010095 @validator("image_pull_policy")
96 def validate_image_pull_policy(cls, v):
97 values = {
98 "always": "Always",
99 "ifnotpresent": "IfNotPresent",
100 "never": "Never",
101 }
102 v = v.lower()
103 if v not in values.keys():
104 raise ValueError("value must be always, ifnotpresent or never")
105 return values[v]
106
sousaedub17e76b2021-01-26 12:58:25 +0100107
David Garcia49379ce2021-02-24 13:48:22 +0100108class GrafanaCharm(CharmedOsmBase):
109 """GrafanaCharm Charm."""
sousaedub17e76b2021-01-26 12:58:25 +0100110
111 def __init__(self, *args) -> NoReturn:
David Garcia49379ce2021-02-24 13:48:22 +0100112 """Prometheus Charm constructor."""
sousaedu28dfe7e2021-06-30 15:03:28 +0100113 super().__init__(*args, oci_image="image", mysql_uri=True)
114 # Initialize relation objects
David Garcia49379ce2021-02-24 13:48:22 +0100115 self.prometheus_client = PrometheusClient(self, "prometheus")
sousaedu28dfe7e2021-06-30 15:03:28 +0100116 self.grafana_cluster = GrafanaCluster(self, "cluster")
117 self.mysql_client = MysqlClient(self, "db")
118 # Observe events
119 event_observer_mapping = {
120 self.on["prometheus"].relation_changed: self.configure_pod,
121 self.on["prometheus"].relation_broken: self.configure_pod,
122 self.on["db"].relation_changed: self.configure_pod,
123 self.on["db"].relation_broken: self.configure_pod,
124 }
125 for event, observer in event_observer_mapping.items():
126 self.framework.observe(event, observer)
sousaedub17e76b2021-01-26 12:58:25 +0100127
David Garcia49379ce2021-02-24 13:48:22 +0100128 def _build_dashboard_files(self, config: ConfigModel):
129 files_builder = FilesV3Builder()
130 files_builder.add_file(
131 "dashboard_osm.yaml",
David Garciad680be42021-08-17 11:03:55 +0200132 Path("templates/default_dashboards.yaml").read_text(),
David Garcia49379ce2021-02-24 13:48:22 +0100133 )
134 if config.osm_dashboards:
135 osm_dashboards_mapping = {
David Garciad680be42021-08-17 11:03:55 +0200136 "kafka_exporter_dashboard.json": "templates/kafka_exporter_dashboard.json",
137 "mongodb_exporter_dashboard.json": "templates/mongodb_exporter_dashboard.json",
138 "mysql_exporter_dashboard.json": "templates/mysql_exporter_dashboard.json",
139 "nodes_exporter_dashboard.json": "templates/nodes_exporter_dashboard.json",
140 "summary_dashboard.json": "templates/summary_dashboard.json",
David Garcia49379ce2021-02-24 13:48:22 +0100141 }
142 for file_name, path in osm_dashboards_mapping.items():
143 files_builder.add_file(file_name, Path(path).read_text())
144 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100145
David Garcia49379ce2021-02-24 13:48:22 +0100146 def _build_datasources_files(self):
147 files_builder = FilesV3Builder()
David Garciade440ed2021-10-11 19:56:53 +0200148 prometheus_user = self.prometheus_client.user
149 prometheus_password = self.prometheus_client.password
150 enable_basic_auth = all([prometheus_user, prometheus_password])
David Garcia33882122021-11-05 12:33:03 +0100151 kwargs = {
152 "prometheus_host": self.prometheus_client.hostname,
153 "prometheus_port": self.prometheus_client.port,
154 "enable_basic_auth": enable_basic_auth,
155 "user": "",
156 "password": "",
157 }
158 if enable_basic_auth:
159 kwargs["user"] = f"basic_auth_user: {prometheus_user}"
160 kwargs[
161 "password"
162 ] = f"secure_json_data:\n basicAuthPassword: {prometheus_password}"
David Garcia49379ce2021-02-24 13:48:22 +0100163 files_builder.add_file(
164 "datasource_prometheus.yaml",
David Garciad680be42021-08-17 11:03:55 +0200165 Template(Path("templates/default_datasources.yaml").read_text()).substitute(
David Garcia33882122021-11-05 12:33:03 +0100166 **kwargs
David Garcia49379ce2021-02-24 13:48:22 +0100167 ),
168 )
169 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100170
sousaedu28dfe7e2021-06-30 15:03:28 +0100171 def _check_missing_dependencies(self, config: ConfigModel, external_db: bool):
David Garcia49379ce2021-02-24 13:48:22 +0100172 missing_relations = []
sousaedub17e76b2021-01-26 12:58:25 +0100173
David Garcia49379ce2021-02-24 13:48:22 +0100174 if self.prometheus_client.is_missing_data_in_app():
175 missing_relations.append("prometheus")
sousaedub17e76b2021-01-26 12:58:25 +0100176
sousaedu28dfe7e2021-06-30 15:03:28 +0100177 if not external_db and self.mysql_client.is_missing_data_in_unit():
178 missing_relations.append("db")
179
David Garcia49379ce2021-02-24 13:48:22 +0100180 if missing_relations:
181 raise RelationsMissing(missing_relations)
sousaedub17e76b2021-01-26 12:58:25 +0100182
sousaedu28dfe7e2021-06-30 15:03:28 +0100183 def build_pod_spec(self, image_info, **kwargs):
David Garcia49379ce2021-02-24 13:48:22 +0100184 # Validate config
185 config = ConfigModel(**dict(self.config))
sousaedu28dfe7e2021-06-30 15:03:28 +0100186 mysql_config = kwargs["mysql_config"]
187 if mysql_config.mysql_uri and not self.mysql_client.is_missing_data_in_unit():
188 raise Exception("Mysql data cannot be provided via config and relation")
189
David Garcia49379ce2021-02-24 13:48:22 +0100190 # Check relations
sousaedu28dfe7e2021-06-30 15:03:28 +0100191 external_db = True if mysql_config.mysql_uri else False
192 self._check_missing_dependencies(config, external_db)
193
194 # Get initial password
195 admin_initial_password = self.grafana_cluster.admin_initial_password
196 if not admin_initial_password:
197 admin_initial_password = _generate_random_password()
198 self.grafana_cluster.set_initial_password(admin_initial_password)
199
David Garcia49379ce2021-02-24 13:48:22 +0100200 # Create Builder for the PodSpec
sousaedu540d9372021-09-29 01:53:30 +0100201 pod_spec_builder = PodSpecV3Builder(
202 enable_security_context=config.security_context
203 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100204
David Garcia141d9352021-09-08 17:48:40 +0200205 # Add secrets to the pod
206 grafana_secret_name = f"{self.app.name}-admin-secret"
207 pod_spec_builder.add_secret(
208 grafana_secret_name,
209 {
210 "admin-password": admin_initial_password,
211 "mysql-url": mysql_config.mysql_uri or self.mysql_client.get_uri(),
212 },
213 )
214
David Garcia49379ce2021-02-24 13:48:22 +0100215 # Build Container
sousaedu3ddbbd12021-08-24 19:57:24 +0100216 container_builder = ContainerV3Builder(
sousaedu540d9372021-09-29 01:53:30 +0100217 self.app.name,
218 image_info,
219 config.image_pull_policy,
220 run_as_non_root=config.security_context,
sousaedu3ddbbd12021-08-24 19:57:24 +0100221 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100222 container_builder.add_port(name=self.app.name, port=config.port)
David Garcia49379ce2021-02-24 13:48:22 +0100223 container_builder.add_http_readiness_probe(
224 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100225 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100226 initial_delay_seconds=10,
227 period_seconds=10,
228 timeout_seconds=5,
229 failure_threshold=3,
230 )
231 container_builder.add_http_liveness_probe(
232 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100233 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100234 initial_delay_seconds=60,
235 timeout_seconds=30,
236 failure_threshold=10,
237 )
238 container_builder.add_volume_config(
239 "dashboards",
240 "/etc/grafana/provisioning/dashboards/",
241 self._build_dashboard_files(config),
242 )
243 container_builder.add_volume_config(
244 "datasources",
245 "/etc/grafana/provisioning/datasources/",
246 self._build_datasources_files(),
247 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100248
249 container_builder.add_envs(
250 {
251 "GF_SERVER_HTTP_PORT": config.port,
252 "GF_LOG_LEVEL": config.log_level,
253 "GF_SECURITY_ADMIN_USER": config.admin_user,
David Garcia141d9352021-09-08 17:48:40 +0200254 }
255 )
256 container_builder.add_secret_envs(
257 secret_name=grafana_secret_name,
258 envs={
259 "GF_SECURITY_ADMIN_PASSWORD": "admin-password",
260 "GF_DATABASE_URL": "mysql-url",
sousaedu28dfe7e2021-06-30 15:03:28 +0100261 },
262 )
David Garcia49379ce2021-02-24 13:48:22 +0100263 container = container_builder.build()
David Garcia49379ce2021-02-24 13:48:22 +0100264 pod_spec_builder.add_container(container)
David Garcia141d9352021-09-08 17:48:40 +0200265
266 # Add Pod restart policy
267 restart_policy = PodRestartPolicy()
268 restart_policy.add_secrets(secret_names=(grafana_secret_name,))
269 pod_spec_builder.set_restart_policy(restart_policy)
270
David Garcia49379ce2021-02-24 13:48:22 +0100271 # Add ingress resources to pod spec if site url exists
272 if config.site_url:
273 parsed = urlparse(config.site_url)
274 annotations = {
275 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
276 str(config.max_file_size) + "m"
277 if config.max_file_size > 0
278 else config.max_file_size
David Garciad68e0b42021-06-28 16:50:42 +0200279 )
David Garcia49379ce2021-02-24 13:48:22 +0100280 }
David Garciad68e0b42021-06-28 16:50:42 +0200281 if config.ingress_class:
282 annotations["kubernetes.io/ingress.class"] = config.ingress_class
David Garcia49379ce2021-02-24 13:48:22 +0100283 ingress_resource_builder = IngressResourceV3Builder(
284 f"{self.app.name}-ingress", annotations
sousaedub17e76b2021-01-26 12:58:25 +0100285 )
sousaedub17e76b2021-01-26 12:58:25 +0100286
David Garcia49379ce2021-02-24 13:48:22 +0100287 if config.ingress_whitelist_source_range:
288 annotations[
289 "nginx.ingress.kubernetes.io/whitelist-source-range"
290 ] = config.ingress_whitelist_source_range
sousaedub17e76b2021-01-26 12:58:25 +0100291
sousaedu3cc03162021-04-29 16:53:12 +0200292 if config.cluster_issuer:
293 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
294
David Garcia49379ce2021-02-24 13:48:22 +0100295 if parsed.scheme == "https":
296 ingress_resource_builder.add_tls(
297 [parsed.hostname], config.tls_secret_name
298 )
299 else:
300 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
301
sousaedu28dfe7e2021-06-30 15:03:28 +0100302 ingress_resource_builder.add_rule(
303 parsed.hostname, self.app.name, config.port
304 )
David Garcia49379ce2021-02-24 13:48:22 +0100305 ingress_resource = ingress_resource_builder.build()
306 pod_spec_builder.add_ingress_resource(ingress_resource)
307 return pod_spec_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100308
309
sousaedu28dfe7e2021-06-30 15:03:28 +0100310def _generate_random_password():
311 return secrets.token_hex(16)
312
313
sousaedub17e76b2021-01-26 12:58:25 +0100314if __name__ == "__main__":
315 main(GrafanaCharm)