8d525f3aba11695ba2ed1f6b7aeb8b2ee4e1067c
[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_host": lambda value, _: isinstance(value, str) and len(value) > 0,
101 "prometheus_port": lambda value, _: isinstance(value, str)
102 and len(value) > 0
103 and int(value) > 0,
104 }
105 problems = []
106
107 for key, validator in config_validators.items():
108 valid = validator(config_data.get(key), config_data)
109
110 if not valid:
111 problems.append(key)
112
113 for key, validator in relation_validators.items():
114 valid = validator(relation_data.get(key), relation_data)
115
116 if not valid:
117 problems.append(key)
118
119 if len(problems) > 0:
120 raise ValueError("Errors found in: {}".format(", ".join(problems)))
121
122 return True
123
124
125 def _make_pod_ports(port: int) -> List[Dict[str, Any]]:
126 """Generate pod ports details.
127
128 Args:
129 port (int): port to expose.
130
131 Returns:
132 List[Dict[str, Any]]: pod port details.
133 """
134 return [{"name": "grafana", "containerPort": port, "protocol": "TCP"}]
135
136
137 def _make_pod_envconfig(
138 config: Dict[str, Any], relation_state: Dict[str, Any]
139 ) -> Dict[str, Any]:
140 """Generate pod environment configuration.
141
142 Args:
143 config (Dict[str, Any]): configuration information.
144 relation_state (Dict[str, Any]): relation state information.
145
146 Returns:
147 Dict[str, Any]: pod environment configuration.
148 """
149 envconfig = {}
150
151 return envconfig
152
153
154 def _make_pod_ingress_resources(
155 config: Dict[str, Any], app_name: str, port: int
156 ) -> List[Dict[str, Any]]:
157 """Generate pod ingress resources.
158
159 Args:
160 config (Dict[str, Any]): configuration information.
161 app_name (str): application name.
162 port (int): port to expose.
163
164 Returns:
165 List[Dict[str, Any]]: pod ingress resources.
166 """
167 site_url = config.get("site_url")
168
169 if not site_url:
170 return
171
172 parsed = urlparse(site_url)
173
174 if not parsed.scheme.startswith("http"):
175 return
176
177 max_file_size = config["max_file_size"]
178 ingress_whitelist_source_range = config["ingress_whitelist_source_range"]
179
180 annotations = {
181 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
182 str(max_file_size) + "m" if max_file_size > 0 else max_file_size
183 ),
184 }
185
186 if ingress_whitelist_source_range:
187 annotations[
188 "nginx.ingress.kubernetes.io/whitelist-source-range"
189 ] = ingress_whitelist_source_range
190
191 ingress_spec_tls = None
192
193 if parsed.scheme == "https":
194 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
195 tls_secret_name = config["tls_secret_name"]
196 if tls_secret_name:
197 ingress_spec_tls[0]["secretName"] = tls_secret_name
198 else:
199 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
200
201 ingress = {
202 "name": "{}-ingress".format(app_name),
203 "annotations": annotations,
204 "spec": {
205 "rules": [
206 {
207 "host": parsed.hostname,
208 "http": {
209 "paths": [
210 {
211 "path": "/",
212 "backend": {
213 "serviceName": app_name,
214 "servicePort": port,
215 },
216 }
217 ]
218 },
219 }
220 ]
221 },
222 }
223 if ingress_spec_tls:
224 ingress["spec"]["tls"] = ingress_spec_tls
225
226 return [ingress]
227
228
229 def _make_pod_files(
230 config: Dict[str, Any], relation: Dict[str, Any]
231 ) -> List[Dict[str, Any]]:
232 """Generating ConfigMap information
233
234 Args:
235 config (Dict[str, Any]): configuration information.
236 relation (Dict[str, Any]): relation information.
237
238 Returns:
239 List[Dict[str, Any]]: ConfigMap information.
240 """
241 template_data = {**config, **relation}
242 dashboards = []
243
244 if config.get("osm_dashboards", False):
245 dashboards.extend(
246 [
247 {
248 "path": "kafka_exporter_dashboard.yaml",
249 "content": Template(
250 Path("files/kafka_exporter_dashboard.yaml").read_text()
251 ),
252 },
253 {
254 "path": "mongodb_exporter_dashboard.yaml",
255 "content": Template(
256 Path("files/mongodb_exporter_dashboard.yaml").read_text()
257 ),
258 },
259 {
260 "path": "mysql_exporter_dashboard.yaml",
261 "content": Template(
262 Path("files/mysql_exporter_dashboard.yaml").read_text()
263 ),
264 },
265 {
266 "path": "nodes_exporter_dashboard.yaml",
267 "content": Template(
268 Path("files/nodes_exporter_dashboard.yaml").read_text()
269 ),
270 },
271 {
272 "path": "summary_dashboard.yaml",
273 "content": Template(
274 Path("files/summary_dashboard.yaml").read_text()
275 ),
276 },
277 ]
278 )
279
280 dashboards.append(
281 {
282 "path": "dashboard_osm.yaml",
283 "content": Template(Path("files/default_dashboards.yaml").read_text()),
284 }
285 )
286
287 files = [
288 {
289 "name": "dashboards",
290 "mountPath": "/etc/grafana/provisioning/dashboards/",
291 "files": dashboards,
292 },
293 {
294 "name": "datasources",
295 "mountPath": "/etc/grafana/provisioning/datasources/",
296 "files": [
297 {
298 "path": "datasource_prometheus.yaml",
299 "content": Template(
300 Path("files/default_dashboards.yaml").read_text()
301 ).substitute(template_data),
302 }
303 ],
304 },
305 ]
306
307 return files
308
309
310 def _make_readiness_probe(port: int) -> Dict[str, Any]:
311 """Generate readiness probe.
312
313 Args:
314 port (int): service port.
315
316 Returns:
317 Dict[str, Any]: readiness probe.
318 """
319 return {
320 "httpGet": {
321 "path": "/api/health",
322 "port": port,
323 },
324 "initialDelaySeconds": 10,
325 "periodSeconds": 10,
326 "timeoutSeconds": 5,
327 "successThreshold": 1,
328 "failureThreshold": 3,
329 }
330
331
332 def _make_liveness_probe(port: int) -> Dict[str, Any]:
333 """Generate liveness probe.
334
335 Args:
336 port (int): service port.
337
338 Returns:
339 Dict[str, Any]: liveness probe.
340 """
341 return {
342 "httpGet": {
343 "path": "/api/health",
344 "port": port,
345 },
346 "initialDelaySeconds": 60,
347 "timeoutSeconds": 30,
348 "failureThreshold": 10,
349 }
350
351
352 def make_pod_spec(
353 image_info: Dict[str, str],
354 config: Dict[str, Any],
355 relation_state: Dict[str, Any],
356 app_name: str = "grafana",
357 port: int = 3000,
358 ) -> Dict[str, Any]:
359 """Generate the pod spec information.
360
361 Args:
362 image_info (Dict[str, str]): Object provided by
363 OCIImageResource("image").fetch().
364 config (Dict[str, Any]): Configuration information.
365 relation_state (Dict[str, Any]): Relation state information.
366 app_name (str, optional): Application name. Defaults to "ro".
367 port (int, optional): Port for the container. Defaults to 9090.
368
369 Returns:
370 Dict[str, Any]: Pod spec dictionary for the charm.
371 """
372 if not image_info:
373 return None
374
375 _validate_data(config, relation_state)
376
377 ports = _make_pod_ports(port)
378 env_config = _make_pod_envconfig(config, relation_state)
379 files = _make_pod_files(relation_state)
380 readiness_probe = _make_readiness_probe(port)
381 liveness_probe = _make_liveness_probe(port)
382 ingress_resources = _make_pod_ingress_resources(config, app_name, port)
383
384 return {
385 "version": 3,
386 "containers": [
387 {
388 "name": app_name,
389 "imageDetails": image_info,
390 "imagePullPolicy": "Always",
391 "ports": ports,
392 "envConfig": env_config,
393 "volumeConfig": files,
394 "kubernetes": {
395 "readinessProbe": readiness_probe,
396 "livenessProbe": liveness_probe,
397 },
398 }
399 ],
400 "kubernetesResources": {
401 "ingressResources": ingress_resources or [],
402 },
403 }