b6291d426e0eb76bc1dbb76ecf9f8faebbcf7b90
[osm/devops.git] / installers / charm / nbi / src / pod_spec.py
1 #!/usr/bin/env python3
2 # Copyright 2020 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 from ipaddress import ip_network
24 from typing import Any, Callable, Dict, List, NoReturn
25 from urllib.parse import urlparse
26
27
28 def _validate_max_file_size(max_file_size: int, site_url: str) -> bool:
29 """Validate max_file_size.
30
31 Args:
32 max_file_size (int): maximum file size allowed.
33 site_url (str): endpoint url.
34
35 Returns:
36 bool: True if valid, false otherwise.
37 """
38 if not site_url:
39 return True
40
41 parsed = urlparse(site_url)
42
43 if not parsed.scheme.startswith("http"):
44 return True
45
46 if max_file_size is None:
47 return False
48
49 return max_file_size >= 0
50
51
52 def _validate_ip_network(network: str) -> bool:
53 """Validate IP network.
54
55 Args:
56 network (str): IP network range.
57
58 Returns:
59 bool: True if valid, false otherwise.
60 """
61 if not network:
62 return True
63
64 try:
65 ip_network(network)
66 except ValueError:
67 return False
68
69 return True
70
71
72 def _validate_keystone_config(keystone: bool, value: Any, validator: Callable) -> bool:
73 """Validate keystone configurations.
74
75 Args:
76 keystone (bool): is keystone enabled, true if so, false otherwise.
77 value (Any): value to be validated.
78 validator (Callable): function to validate configuration.
79
80 Returns:
81 bool: true if valid, false otherwise.
82 """
83 if not keystone:
84 return True
85
86 return validator(value)
87
88
89 def _validate_data(
90 config_data: Dict[str, Any], relation_data: Dict[str, Any], keystone: bool
91 ) -> NoReturn:
92 """Validate input data.
93
94 Args:
95 config_data (Dict[str, Any]): configuration data.
96 relation_data (Dict[str, Any]): relation data.
97 keystone (bool): is keystone to be used.
98 """
99 config_validators = {
100 "enable_test": lambda value, _: isinstance(value, bool),
101 "database_commonkey": lambda value, _: isinstance(value, str)
102 and len(value) > 1,
103 "log_level": lambda value, _: isinstance(value, str)
104 and value in ("INFO", "DEBUG"),
105 "auth_backend": lambda value, _: isinstance(value, str)
106 and (value == "internal" or value == "keystone"),
107 "site_url": lambda value, _: isinstance(value, str)
108 if value is not None
109 else True,
110 "max_file_size": lambda value, values: _validate_max_file_size(
111 value, values.get("site_url")
112 ),
113 "ingress_whitelist_source_range": lambda value, _: _validate_ip_network(value),
114 "tls_secret_name": lambda value, _: isinstance(value, str)
115 if value is not None
116 else True,
117 }
118 relation_validators = {
119 "message_host": lambda value, _: isinstance(value, str),
120 "message_port": lambda value, _: isinstance(value, int) and value > 0,
121 "database_uri": lambda value, _: isinstance(value, str)
122 and value.startswith("mongodb://"),
123 "prometheus_host": lambda value, _: isinstance(value, str),
124 "prometheus_port": lambda value, _: isinstance(value, int) and value > 0,
125 "keystone_host": lambda value, _: _validate_keystone_config(
126 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
127 ),
128 "keystone_port": lambda value, _: _validate_keystone_config(
129 keystone, value, lambda x: isinstance(x, int) and x > 0
130 ),
131 "keystone_user_domain_name": lambda value, _: _validate_keystone_config(
132 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
133 ),
134 "keystone_project_domain_name": lambda value, _: _validate_keystone_config(
135 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
136 ),
137 "keystone_username": lambda value, _: _validate_keystone_config(
138 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
139 ),
140 "keystone_password": lambda value, _: _validate_keystone_config(
141 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
142 ),
143 "keystone_service": lambda value, _: _validate_keystone_config(
144 keystone, value, lambda x: isinstance(x, str) and len(x) > 0
145 ),
146 }
147 problems = []
148
149 for key, validator in config_validators.items():
150 valid = validator(config_data.get(key), config_data)
151
152 if not valid:
153 problems.append(key)
154
155 for key, validator in relation_validators.items():
156 valid = validator(relation_data.get(key), relation_data)
157
158 if not valid:
159 problems.append(key)
160
161 if len(problems) > 0:
162 raise ValueError("Errors found in: {}".format(", ".join(problems)))
163
164
165 def _make_pod_ports(port: int) -> List[Dict[str, Any]]:
166 """Generate pod ports details.
167
168 Args:
169 port (int): port to expose.
170
171 Returns:
172 List[Dict[str, Any]]: pod port details.
173 """
174 return [{"name": "nbi", "containerPort": port, "protocol": "TCP"}]
175
176
177 def _make_pod_envconfig(
178 config: Dict[str, Any], relation_state: Dict[str, Any]
179 ) -> Dict[str, Any]:
180 """Generate pod environment configuration.
181
182 Args:
183 config (Dict[str, Any]): configuration information.
184 relation_state (Dict[str, Any]): relation state information.
185
186 Returns:
187 Dict[str, Any]: pod environment configuration.
188 """
189 envconfig = {
190 # General configuration
191 "ALLOW_ANONYMOUS_LOGIN": "yes",
192 "OSMNBI_SERVER_ENABLE_TEST": config["enable_test"],
193 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
194 # Kafka configuration
195 "OSMNBI_MESSAGE_HOST": relation_state["message_host"],
196 "OSMNBI_MESSAGE_DRIVER": "kafka",
197 "OSMNBI_MESSAGE_PORT": relation_state["message_port"],
198 # Database configuration
199 "OSMNBI_DATABASE_DRIVER": "mongo",
200 "OSMNBI_DATABASE_URI": relation_state["database_uri"],
201 "OSMNBI_DATABASE_COMMONKEY": config["database_commonkey"],
202 # Storage configuration
203 "OSMNBI_STORAGE_DRIVER": "mongo",
204 "OSMNBI_STORAGE_PATH": "/app/storage",
205 "OSMNBI_STORAGE_COLLECTION": "files",
206 "OSMNBI_STORAGE_URI": relation_state["database_uri"],
207 # Prometheus configuration
208 "OSMNBI_PROMETHEUS_HOST": relation_state["prometheus_host"],
209 "OSMNBI_PROMETHEUS_PORT": relation_state["prometheus_port"],
210 # Log configuration
211 "OSMNBI_LOG_LEVEL": config["log_level"],
212 }
213
214 if config["auth_backend"] == "internal":
215 envconfig["OSMNBI_AUTHENTICATION_BACKEND"] = "internal"
216 elif config["auth_backend"] == "keystone":
217 envconfig.update(
218 {
219 "OSMNBI_AUTHENTICATION_BACKEND": "keystone",
220 "OSMNBI_AUTHENTICATION_AUTH_URL": relation_state["keystone_host"],
221 "OSMNBI_AUTHENTICATION_AUTH_PORT": relation_state["keystone_port"],
222 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": relation_state[
223 "keystone_user_domain_name"
224 ],
225 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": relation_state[
226 "keystone_project_domain_name"
227 ],
228 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": relation_state[
229 "keystone_username"
230 ],
231 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": relation_state[
232 "keystone_password"
233 ],
234 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": relation_state[
235 "keystone_service"
236 ],
237 }
238 )
239 else:
240 raise ValueError("auth_backend needs to be either internal or keystone")
241
242 return envconfig
243
244
245 def _make_pod_ingress_resources(
246 config: Dict[str, Any], app_name: str, port: int
247 ) -> List[Dict[str, Any]]:
248 """Generate pod ingress resources.
249
250 Args:
251 config (Dict[str, Any]): configuration information.
252 app_name (str): application name.
253 port (int): port to expose.
254
255 Returns:
256 List[Dict[str, Any]]: pod ingress resources.
257 """
258 site_url = config.get("site_url")
259
260 if not site_url:
261 return
262
263 parsed = urlparse(site_url)
264
265 if not parsed.scheme.startswith("http"):
266 return
267
268 max_file_size = config["max_file_size"]
269 ingress_whitelist_source_range = config["ingress_whitelist_source_range"]
270
271 annotations = {
272 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
273 str(max_file_size) + "m" if max_file_size > 0 else max_file_size
274 ),
275 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
276 }
277
278 if ingress_whitelist_source_range:
279 annotations[
280 "nginx.ingress.kubernetes.io/whitelist-source-range"
281 ] = ingress_whitelist_source_range
282
283 ingress_spec_tls = None
284
285 if parsed.scheme == "https":
286 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
287 tls_secret_name = config["tls_secret_name"]
288 if tls_secret_name:
289 ingress_spec_tls[0]["secretName"] = tls_secret_name
290 else:
291 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
292
293 ingress = {
294 "name": "{}-ingress".format(app_name),
295 "annotations": annotations,
296 "spec": {
297 "rules": [
298 {
299 "host": parsed.hostname,
300 "http": {
301 "paths": [
302 {
303 "path": "/",
304 "backend": {
305 "serviceName": app_name,
306 "servicePort": port,
307 },
308 }
309 ]
310 },
311 }
312 ]
313 },
314 }
315 if ingress_spec_tls:
316 ingress["spec"]["tls"] = ingress_spec_tls
317
318 return [ingress]
319
320
321 def _make_startup_probe() -> Dict[str, Any]:
322 """Generate startup probe.
323
324 Returns:
325 Dict[str, Any]: startup probe.
326 """
327 return {
328 "exec": {"command": ["/usr/bin/pgrep python3"]},
329 "initialDelaySeconds": 60,
330 "timeoutSeconds": 5,
331 }
332
333
334 def _make_readiness_probe(port: int) -> Dict[str, Any]:
335 """Generate readiness probe.
336
337 Args:
338 port (int): [description]
339
340 Returns:
341 Dict[str, Any]: readiness probe.
342 """
343 return {
344 "httpGet": {
345 "path": "/osm/",
346 "port": port,
347 },
348 "initialDelaySeconds": 45,
349 "timeoutSeconds": 5,
350 }
351
352
353 def _make_liveness_probe(port: int) -> Dict[str, Any]:
354 """Generate liveness probe.
355
356 Args:
357 port (int): [description]
358
359 Returns:
360 Dict[str, Any]: liveness probe.
361 """
362 return {
363 "httpGet": {
364 "path": "/osm/",
365 "port": port,
366 },
367 "initialDelaySeconds": 45,
368 "timeoutSeconds": 5,
369 }
370
371
372 def make_pod_spec(
373 image_info: Dict[str, str],
374 config: Dict[str, Any],
375 relation_state: Dict[str, Any],
376 app_name: str = "nbi",
377 port: int = 9999,
378 ) -> Dict[str, Any]:
379 """Generate the pod spec information.
380
381 Args:
382 image_info (Dict[str, str]): Object provided by
383 OCIImageResource("image").fetch().
384 config (Dict[str, Any]): Configuration information.
385 relation_state (Dict[str, Any]): Relation state information.
386 app_name (str, optional): Application name. Defaults to "nbi".
387 port (int, optional): Port for the container. Defaults to 9999.
388
389 Returns:
390 Dict[str, Any]: Pod spec dictionary for the charm.
391 """
392 if not image_info:
393 return None
394
395 _validate_data(config, relation_state, config.get("auth_backend") == "keystone")
396
397 ports = _make_pod_ports(port)
398 env_config = _make_pod_envconfig(config, relation_state)
399 ingress_resources = _make_pod_ingress_resources(config, app_name, port)
400
401 return {
402 "version": 3,
403 "containers": [
404 {
405 "name": app_name,
406 "imageDetails": image_info,
407 "imagePullPolicy": "Always",
408 "ports": ports,
409 "envConfig": env_config,
410 }
411 ],
412 "kubernetesResources": {
413 "ingressResources": ingress_resources or [],
414 },
415 }