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