| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 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 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 23 | from ipaddress import ip_network |
| 24 | from typing import Any, Callable, Dict, List, NoReturn |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 25 | from urllib.parse import urlparse |
| 26 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 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 |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 50 | |
| 51 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 52 | def _validate_ip_network(network: str) -> bool: |
| 53 | """Validate IP network. |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 54 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 55 | Args: |
| 56 | network (str): IP network range. |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 57 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 58 | Returns: |
| 59 | bool: True if valid, false otherwise. |
| 60 | """ |
| 61 | if not network: |
| 62 | return True |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 63 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 64 | try: |
| 65 | ip_network(network) |
| 66 | except ValueError: |
| 67 | return False |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 68 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 69 | return True |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 70 | |
| 71 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 72 | def _validate_keystone_config(keystone: bool, value: Any, validator: Callable) -> bool: |
| 73 | """Validate keystone configurations. |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 74 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 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. |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 79 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 80 | Returns: |
| 81 | bool: true if valid, false otherwise. |
| 82 | """ |
| 83 | if not keystone: |
| 84 | return True |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 85 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 86 | return validator(value) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 87 | |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 88 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 89 | def _validate_data( |
| 90 | config_data: Dict[str, Any], relation_data: Dict[str, Any], keystone: bool |
| 91 | ) -> NoReturn: |
| 92 | """Validate input data. |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 93 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 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 == "INFO" or value == "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 = [] |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 148 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 149 | for key, validator in config_validators.items(): |
| 150 | valid = validator(config_data.get(key), config_data) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 151 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 152 | if not valid: |
| 153 | problems.append(key) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 154 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 155 | for key, validator in relation_validators.items(): |
| 156 | valid = validator(relation_data.get(key), relation_data) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 157 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 158 | if not valid: |
| 159 | problems.append(key) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 160 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 161 | if len(problems) > 0: |
| 162 | raise ValueError("Errors found in: {}".format(", ".join(problems))) |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 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( |
| David Garcia | ef349d9 | 2020-12-10 21:16:12 +0100 | [diff] [blame] | 273 | str(max_file_size) + "m" if max_file_size > 0 else max_file_size |
| David Garcia | 64b4b43 | 2020-11-11 12:51:38 +0100 | [diff] [blame] | 274 | ), |
| 275 | "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 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 | |
| sousaedu | af9dcc2 | 2020-12-10 01:22:09 +0000 | [diff] [blame] | 395 | _validate_data(config, relation_state, config.get("auth_backend") == "keystone") |
| sousaedu | 6248fe6 | 2020-10-13 23:46:51 +0100 | [diff] [blame] | 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 | } |