Major improvement in OSM charms
[osm/devops.git] / installers / charm / grafana / src / pod_spec.py
1 #!/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
23 import logging
24 from ipaddress import ip_network
25 from typing import Any, Dict, List
26 from urllib.parse import urlparse
27 from pathlib import Path
28 from string import Template
29
30 logger = logging.getLogger(__name__)
31
32
33 def _validate_max_file_size(max_file_size: int, site_url: str) -> bool:
34 """Validate max_file_size.
35
36 Args:
37 max_file_size (int): maximum file size allowed.
38 site_url (str): endpoint url.
39
40 Returns:
41 bool: True if valid, false otherwise.
42 """
43 if not site_url:
44 return True
45
46 parsed = urlparse(site_url)
47
48 if not parsed.scheme.startswith("http"):
49 return True
50
51 if max_file_size is None:
52 return False
53
54 return max_file_size >= 0
55
56
57 def _validate_ip_network(network: str) -> bool:
58 """Validate IP network.
59
60 Args:
61 network (str): IP network range.
62
63 Returns:
64 bool: True if valid, false otherwise.
65 """
66 if not network:
67 return True
68
69 try:
70 ip_network(network)
71 except ValueError:
72 return False
73
74 return True
75
76
77 def _validate_data(config_data: Dict[str, Any], relation_data: Dict[str, Any]) -> bool:
78 """Validates passed information.
79
80 Args:
81 config_data (Dict[str, Any]): configuration information.
82 relation_data (Dict[str, Any]): relation information
83
84 Raises:
85 ValueError: when config and/or relation data is not valid.
86 """
87 config_validators = {
88 "site_url": lambda value, _: isinstance(value, str)
89 if value is not None
90 else True,
91 "max_file_size": lambda value, values: _validate_max_file_size(
92 value, values.get("site_url")
93 ),
94 "ingress_whitelist_source_range": lambda value, _: _validate_ip_network(value),
95 "tls_secret_name": lambda value, _: isinstance(value, str)
96 if value is not None
97 else True,
98 }
99 relation_validators = {
100 "prometheus_hostname": lambda value, _: (
101 isinstance(value, str) and len(value) > 0
102 ),
103 "prometheus_port": lambda value, _: (
104 isinstance(value, str) and len(value) > 0 and int(value) > 0
105 ),
106 }
107 problems = []
108
109 for key, validator in config_validators.items():
110 valid = validator(config_data.get(key), config_data)
111
112 if not valid:
113 problems.append(key)
114
115 for key, validator in relation_validators.items():
116 valid = validator(relation_data.get(key), relation_data)
117
118 if not valid:
119 problems.append(key)
120
121 if len(problems) > 0:
122 logger.debug(relation_data)
123 raise ValueError("Errors found in: {}".format(", ".join(problems)))
124
125 return True
126
127
128 def _make_pod_ports(port: int) -> List[Dict[str, Any]]:
129 """Generate pod ports details.
130
131 Args:
132 port (int): port to expose.
133
134 Returns:
135 List[Dict[str, Any]]: pod port details.
136 """
137 return [{"name": "grafana", "containerPort": port, "protocol": "TCP"}]
138
139
140 def _make_pod_envconfig(
141 config: Dict[str, Any], relation_state: Dict[str, Any]
142 ) -> Dict[str, Any]:
143 """Generate pod environment configuration.
144
145 Args:
146 config (Dict[str, Any]): configuration information.
147 relation_state (Dict[str, Any]): relation state information.
148
149 Returns:
150 Dict[str, Any]: pod environment configuration.
151 """
152 envconfig = {}
153
154 return envconfig
155
156
157 def _make_pod_ingress_resources(
158 config: Dict[str, Any], app_name: str, port: int
159 ) -> List[Dict[str, Any]]:
160 """Generate pod ingress resources.
161
162 Args:
163 config (Dict[str, Any]): configuration information.
164 app_name (str): application name.
165 port (int): port to expose.
166
167 Returns:
168 List[Dict[str, Any]]: pod ingress resources.
169 """
170 site_url = config.get("site_url")
171
172 if not site_url:
173 return
174
175 parsed = urlparse(site_url)
176
177 if not parsed.scheme.startswith("http"):
178 return
179
180 max_file_size = config["max_file_size"]
181 ingress_whitelist_source_range = config["ingress_whitelist_source_range"]
182
183 annotations = {
184 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
185 str(max_file_size) + "m" if max_file_size > 0 else max_file_size
186 ),
187 }
188
189 if ingress_whitelist_source_range:
190 annotations[
191 "nginx.ingress.kubernetes.io/whitelist-source-range"
192 ] = ingress_whitelist_source_range
193
194 ingress_spec_tls = None
195
196 if parsed.scheme == "https":
197 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
198 tls_secret_name = config["tls_secret_name"]
199 if tls_secret_name:
200 ingress_spec_tls[0]["secretName"] = tls_secret_name
201 else:
202 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
203
204 ingress = {
205 "name": "{}-ingress".format(app_name),
206 "annotations": annotations,
207 "spec": {
208 "rules": [
209 {
210 "host": parsed.hostname,
211 "http": {
212 "paths": [
213 {
214 "path": "/",
215 "backend": {
216 "serviceName": app_name,
217 "servicePort": port,
218 },
219 }
220 ]
221 },
222 }
223 ]
224 },
225 }
226 if ingress_spec_tls:
227 ingress["spec"]["tls"] = ingress_spec_tls
228
229 return [ingress]
230
231
232 def _make_pod_files(
233 config: Dict[str, Any], relation: Dict[str, Any]
234 ) -> List[Dict[str, Any]]:
235 """Generating ConfigMap information
236
237 Args:
238 config (Dict[str, Any]): configuration information.
239 relation (Dict[str, Any]): relation information.
240
241 Returns:
242 List[Dict[str, Any]]: ConfigMap information.
243 """
244 template_data = {**config, **relation}
245 dashboards = []
246
247 if config.get("osm_dashboards", False):
248 dashboards.extend(
249 [
250 {
251 "path": "kafka_exporter_dashboard.yaml",
252 "content": Path("files/kafka_exporter_dashboard.yaml").read_text(),
253 },
254 {
255 "path": "mongodb_exporter_dashboard.yaml",
256 "content": Path(
257 "files/mongodb_exporter_dashboard.yaml"
258 ).read_text(),
259 },
260 {
261 "path": "mysql_exporter_dashboard.yaml",
262 "content": Path("files/mysql_exporter_dashboard.yaml").read_text(),
263 },
264 {
265 "path": "nodes_exporter_dashboard.yaml",
266 "content": Path("files/nodes_exporter_dashboard.yaml").read_text(),
267 },
268 {
269 "path": "summary_dashboard.yaml",
270 "content": Path("files/summary_dashboard.yaml").read_text(),
271 },
272 ]
273 )
274
275 dashboards.append(
276 {
277 "path": "dashboard_osm.yaml",
278 "content": Path("files/default_dashboards.yaml").read_text(),
279 }
280 )
281
282 files = [
283 {
284 "name": "dashboards",
285 "mountPath": "/etc/grafana/provisioning/dashboards/",
286 "files": dashboards,
287 },
288 {
289 "name": "datasources",
290 "mountPath": "/etc/grafana/provisioning/datasources/",
291 "files": [
292 {
293 "path": "datasource_prometheus.yaml",
294 "content": Template(
295 Path("files/default_dashboards.yaml").read_text()
296 ).substitute(template_data),
297 }
298 ],
299 },
300 ]
301
302 return files
303
304
305 def _make_readiness_probe(port: int) -> Dict[str, Any]:
306 """Generate readiness probe.
307
308 Args:
309 port (int): service port.
310
311 Returns:
312 Dict[str, Any]: readiness probe.
313 """
314 return {
315 "httpGet": {
316 "path": "/api/health",
317 "port": port,
318 },
319 "initialDelaySeconds": 10,
320 "periodSeconds": 10,
321 "timeoutSeconds": 5,
322 "successThreshold": 1,
323 "failureThreshold": 3,
324 }
325
326
327 def _make_liveness_probe(port: int) -> Dict[str, Any]:
328 """Generate liveness probe.
329
330 Args:
331 port (int): service port.
332
333 Returns:
334 Dict[str, Any]: liveness probe.
335 """
336 return {
337 "httpGet": {
338 "path": "/api/health",
339 "port": port,
340 },
341 "initialDelaySeconds": 60,
342 "timeoutSeconds": 30,
343 "failureThreshold": 10,
344 }
345
346
347 def make_pod_spec(
348 image_info: Dict[str, str],
349 config: Dict[str, Any],
350 relation_state: Dict[str, Any],
351 app_name: str = "grafana",
352 port: int = 3000,
353 ) -> Dict[str, Any]:
354 """Generate the pod spec information.
355
356 Args:
357 image_info (Dict[str, str]): Object provided by
358 OCIImageResource("image").fetch().
359 config (Dict[str, Any]): Configuration information.
360 relation_state (Dict[str, Any]): Relation state information.
361 app_name (str, optional): Application name. Defaults to "ro".
362 port (int, optional): Port for the container. Defaults to 9090.
363
364 Returns:
365 Dict[str, Any]: Pod spec dictionary for the charm.
366 """
367 if not image_info:
368 return None
369
370 _validate_data(config, relation_state)
371
372 ports = _make_pod_ports(port)
373 env_config = _make_pod_envconfig(config, relation_state)
374 files = _make_pod_files(config, relation_state)
375 readiness_probe = _make_readiness_probe(port)
376 liveness_probe = _make_liveness_probe(port)
377 ingress_resources = _make_pod_ingress_resources(config, app_name, port)
378
379 return {
380 "version": 3,
381 "containers": [
382 {
383 "name": app_name,
384 "imageDetails": image_info,
385 "imagePullPolicy": "Always",
386 "ports": ports,
387 "envConfig": env_config,
388 "volumeConfig": files,
389 "kubernetes": {
390 "readinessProbe": readiness_probe,
391 "livenessProbe": liveness_probe,
392 },
393 }
394 ],
395 "kubernetesResources": {
396 "ingressResources": ingress_resources or [],
397 },
398 }