blob: 778b5eb5bbb40588d08a5d3ac9a1950dd0791410 [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 Garcia49379ce2021-02-24 13:48:22 +0100151 files_builder.add_file(
152 "datasource_prometheus.yaml",
David Garciad680be42021-08-17 11:03:55 +0200153 Template(Path("templates/default_datasources.yaml").read_text()).substitute(
David Garcia49379ce2021-02-24 13:48:22 +0100154 prometheus_host=self.prometheus_client.hostname,
155 prometheus_port=self.prometheus_client.port,
David Garciade440ed2021-10-11 19:56:53 +0200156 enable_basic_auth=enable_basic_auth,
157 user=f"user: {prometheus_user}" if enable_basic_auth else "",
158 password=f"password: {prometheus_password}"
159 if enable_basic_auth
160 else "",
David Garcia49379ce2021-02-24 13:48:22 +0100161 ),
162 )
163 return files_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100164
sousaedu28dfe7e2021-06-30 15:03:28 +0100165 def _check_missing_dependencies(self, config: ConfigModel, external_db: bool):
David Garcia49379ce2021-02-24 13:48:22 +0100166 missing_relations = []
sousaedub17e76b2021-01-26 12:58:25 +0100167
David Garcia49379ce2021-02-24 13:48:22 +0100168 if self.prometheus_client.is_missing_data_in_app():
169 missing_relations.append("prometheus")
sousaedub17e76b2021-01-26 12:58:25 +0100170
sousaedu28dfe7e2021-06-30 15:03:28 +0100171 if not external_db and self.mysql_client.is_missing_data_in_unit():
172 missing_relations.append("db")
173
David Garcia49379ce2021-02-24 13:48:22 +0100174 if missing_relations:
175 raise RelationsMissing(missing_relations)
sousaedub17e76b2021-01-26 12:58:25 +0100176
sousaedu28dfe7e2021-06-30 15:03:28 +0100177 def build_pod_spec(self, image_info, **kwargs):
David Garcia49379ce2021-02-24 13:48:22 +0100178 # Validate config
179 config = ConfigModel(**dict(self.config))
sousaedu28dfe7e2021-06-30 15:03:28 +0100180 mysql_config = kwargs["mysql_config"]
181 if mysql_config.mysql_uri and not self.mysql_client.is_missing_data_in_unit():
182 raise Exception("Mysql data cannot be provided via config and relation")
183
David Garcia49379ce2021-02-24 13:48:22 +0100184 # Check relations
sousaedu28dfe7e2021-06-30 15:03:28 +0100185 external_db = True if mysql_config.mysql_uri else False
186 self._check_missing_dependencies(config, external_db)
187
188 # Get initial password
189 admin_initial_password = self.grafana_cluster.admin_initial_password
190 if not admin_initial_password:
191 admin_initial_password = _generate_random_password()
192 self.grafana_cluster.set_initial_password(admin_initial_password)
193
David Garcia49379ce2021-02-24 13:48:22 +0100194 # Create Builder for the PodSpec
sousaedu540d9372021-09-29 01:53:30 +0100195 pod_spec_builder = PodSpecV3Builder(
196 enable_security_context=config.security_context
197 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100198
David Garcia141d9352021-09-08 17:48:40 +0200199 # Add secrets to the pod
200 grafana_secret_name = f"{self.app.name}-admin-secret"
201 pod_spec_builder.add_secret(
202 grafana_secret_name,
203 {
204 "admin-password": admin_initial_password,
205 "mysql-url": mysql_config.mysql_uri or self.mysql_client.get_uri(),
206 },
207 )
208
David Garcia49379ce2021-02-24 13:48:22 +0100209 # Build Container
sousaedu3ddbbd12021-08-24 19:57:24 +0100210 container_builder = ContainerV3Builder(
sousaedu540d9372021-09-29 01:53:30 +0100211 self.app.name,
212 image_info,
213 config.image_pull_policy,
214 run_as_non_root=config.security_context,
sousaedu3ddbbd12021-08-24 19:57:24 +0100215 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100216 container_builder.add_port(name=self.app.name, port=config.port)
David Garcia49379ce2021-02-24 13:48:22 +0100217 container_builder.add_http_readiness_probe(
218 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100219 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100220 initial_delay_seconds=10,
221 period_seconds=10,
222 timeout_seconds=5,
223 failure_threshold=3,
224 )
225 container_builder.add_http_liveness_probe(
226 "/api/health",
sousaedu28dfe7e2021-06-30 15:03:28 +0100227 config.port,
David Garcia49379ce2021-02-24 13:48:22 +0100228 initial_delay_seconds=60,
229 timeout_seconds=30,
230 failure_threshold=10,
231 )
232 container_builder.add_volume_config(
233 "dashboards",
234 "/etc/grafana/provisioning/dashboards/",
235 self._build_dashboard_files(config),
236 )
237 container_builder.add_volume_config(
238 "datasources",
239 "/etc/grafana/provisioning/datasources/",
240 self._build_datasources_files(),
241 )
sousaedu28dfe7e2021-06-30 15:03:28 +0100242
243 container_builder.add_envs(
244 {
245 "GF_SERVER_HTTP_PORT": config.port,
246 "GF_LOG_LEVEL": config.log_level,
247 "GF_SECURITY_ADMIN_USER": config.admin_user,
David Garcia141d9352021-09-08 17:48:40 +0200248 }
249 )
250 container_builder.add_secret_envs(
251 secret_name=grafana_secret_name,
252 envs={
253 "GF_SECURITY_ADMIN_PASSWORD": "admin-password",
254 "GF_DATABASE_URL": "mysql-url",
sousaedu28dfe7e2021-06-30 15:03:28 +0100255 },
256 )
David Garcia49379ce2021-02-24 13:48:22 +0100257 container = container_builder.build()
David Garcia49379ce2021-02-24 13:48:22 +0100258 pod_spec_builder.add_container(container)
David Garcia141d9352021-09-08 17:48:40 +0200259
260 # Add Pod restart policy
261 restart_policy = PodRestartPolicy()
262 restart_policy.add_secrets(secret_names=(grafana_secret_name,))
263 pod_spec_builder.set_restart_policy(restart_policy)
264
David Garcia49379ce2021-02-24 13:48:22 +0100265 # Add ingress resources to pod spec if site url exists
266 if config.site_url:
267 parsed = urlparse(config.site_url)
268 annotations = {
269 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
270 str(config.max_file_size) + "m"
271 if config.max_file_size > 0
272 else config.max_file_size
David Garciad68e0b42021-06-28 16:50:42 +0200273 )
David Garcia49379ce2021-02-24 13:48:22 +0100274 }
David Garciad68e0b42021-06-28 16:50:42 +0200275 if config.ingress_class:
276 annotations["kubernetes.io/ingress.class"] = config.ingress_class
David Garcia49379ce2021-02-24 13:48:22 +0100277 ingress_resource_builder = IngressResourceV3Builder(
278 f"{self.app.name}-ingress", annotations
sousaedub17e76b2021-01-26 12:58:25 +0100279 )
sousaedub17e76b2021-01-26 12:58:25 +0100280
David Garcia49379ce2021-02-24 13:48:22 +0100281 if config.ingress_whitelist_source_range:
282 annotations[
283 "nginx.ingress.kubernetes.io/whitelist-source-range"
284 ] = config.ingress_whitelist_source_range
sousaedub17e76b2021-01-26 12:58:25 +0100285
sousaedu3cc03162021-04-29 16:53:12 +0200286 if config.cluster_issuer:
287 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
288
David Garcia49379ce2021-02-24 13:48:22 +0100289 if parsed.scheme == "https":
290 ingress_resource_builder.add_tls(
291 [parsed.hostname], config.tls_secret_name
292 )
293 else:
294 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
295
sousaedu28dfe7e2021-06-30 15:03:28 +0100296 ingress_resource_builder.add_rule(
297 parsed.hostname, self.app.name, config.port
298 )
David Garcia49379ce2021-02-24 13:48:22 +0100299 ingress_resource = ingress_resource_builder.build()
300 pod_spec_builder.add_ingress_resource(ingress_resource)
301 return pod_spec_builder.build()
sousaedub17e76b2021-01-26 12:58:25 +0100302
303
sousaedu28dfe7e2021-06-30 15:03:28 +0100304def _generate_random_password():
305 return secrets.token_hex(16)
306
307
sousaedub17e76b2021-01-26 12:58:25 +0100308if __name__ == "__main__":
309 main(GrafanaCharm)