2 # Copyright 2021 Canonical Ltd.
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
8 # http://www.apache.org/licenses/LICENSE-2.0
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
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
23 # pylint: disable=E0213
26 from ipaddress
import ip_network
29 from typing
import NoReturn
, Optional
30 from urllib
.parse
import urlparse
33 from charms
.kafka_k8s
.v0
.kafka
import KafkaEvents
, KafkaRequires
34 from ops
.main
import main
35 from opslib
.osm
.charm
import CharmedOsmBase
, RelationsMissing
36 from opslib
.osm
.interfaces
.http
import HttpServer
37 from opslib
.osm
.interfaces
.keystone
import KeystoneClient
38 from opslib
.osm
.interfaces
.mongo
import MongoClient
39 from opslib
.osm
.interfaces
.prometheus
import PrometheusClient
40 from opslib
.osm
.pod
import (
42 IngressResourceV3Builder
,
46 from opslib
.osm
.validator
import ModelValidator
, validator
49 logger
= logging
.getLogger(__name__
)
54 class ConfigModel(ModelValidator
):
57 database_commonkey
: str
60 site_url
: Optional
[str]
61 cluster_issuer
: Optional
[str]
62 ingress_class
: Optional
[str]
63 ingress_whitelist_source_range
: Optional
[str]
64 tls_secret_name
: Optional
[str]
65 mongodb_uri
: Optional
[str]
66 image_pull_policy
: str
68 security_context
: bool
69 tcpsocket_liveness_probe
: Optional
[str]
70 tcpsocket_readiness_probe
: Optional
[str]
72 @validator("auth_backend")
73 def validate_auth_backend(cls
, v
):
74 if v
not in {"internal", "keystone"}:
75 raise ValueError("value must be 'internal' or 'keystone'")
78 @validator("log_level")
79 def validate_log_level(cls
, v
):
80 if v
not in {"INFO", "DEBUG"}:
81 raise ValueError("value must be INFO or DEBUG")
84 @validator("max_file_size")
85 def validate_max_file_size(cls
, v
):
87 raise ValueError("value must be equal or greater than 0")
90 @validator("site_url")
91 def validate_site_url(cls
, v
):
94 if not parsed
.scheme
.startswith("http"):
95 raise ValueError("value must start with http")
98 @validator("ingress_whitelist_source_range")
99 def validate_ingress_whitelist_source_range(cls
, v
):
104 @validator("mongodb_uri")
105 def validate_mongodb_uri(cls
, v
):
106 if v
and not v
.startswith("mongodb://"):
107 raise ValueError("mongodb_uri is not properly formed")
110 @validator("image_pull_policy")
111 def validate_image_pull_policy(cls
, v
):
114 "ifnotpresent": "IfNotPresent",
118 if v
not in values
.keys():
119 raise ValueError("value must be always, ifnotpresent or never")
123 def _validate_tcpsocket_probe(probe_str
) -> dict:
125 "initial_delay_seconds",
132 probe_dict
= json
.loads(probe_str
)
133 if all(attribute
in valid_attributes
for attribute
in probe_dict
):
136 "One or more attributes are not accepted by the tcpsocket probe configuration"
140 @validator("tcpsocket_readiness_probe")
141 def validate_tcpsocket_readiness_probe(cls
, v
):
143 return ConfigModel
._validate
_tcpsocket
_probe
(v
)
144 except Exception as e
:
145 raise ValueError(f
"tcpsocket_readiness_probe configuration error: {e}")
147 @validator("tcpsocket_liveness_probe")
148 def validate_tcpsocket_liveness_probe(cls
, v
):
150 return ConfigModel
._validate
_tcpsocket
_probe
(v
)
151 except Exception as e
:
152 raise ValueError(f
"tcpsocket_liveness_probe configuration error: {e}")
155 class NbiCharm(CharmedOsmBase
):
158 def __init__(self
, *args
) -> NoReturn
:
162 vscode_workspace
=VSCODE_WORKSPACE
,
164 if self
.config
.get("debug_mode"):
165 self
.enable_debug_mode(
166 pubkey
=self
.config
.get("debug_pubkey"),
169 "hostpath": self
.config
.get("debug_nbi_local_path"),
170 "container-path": "/usr/lib/python3/dist-packages/osm_nbi",
173 "hostpath": self
.config
.get("debug_common_local_path"),
174 "container-path": "/usr/lib/python3/dist-packages/osm_common",
179 self
.kafka
= KafkaRequires(self
)
180 self
.framework
.observe(self
.on
.kafka_available
, self
.configure_pod
)
181 self
.framework
.observe(self
.on
.kafka_broken
, self
.configure_pod
)
183 self
.mongodb_client
= MongoClient(self
, "mongodb")
184 self
.framework
.observe(self
.on
["mongodb"].relation_changed
, self
.configure_pod
)
185 self
.framework
.observe(self
.on
["mongodb"].relation_broken
, self
.configure_pod
)
187 self
.prometheus_client
= PrometheusClient(self
, "prometheus")
188 self
.framework
.observe(
189 self
.on
["prometheus"].relation_changed
, self
.configure_pod
191 self
.framework
.observe(
192 self
.on
["prometheus"].relation_broken
, self
.configure_pod
195 self
.keystone_client
= KeystoneClient(self
, "keystone")
196 self
.framework
.observe(self
.on
["keystone"].relation_changed
, self
.configure_pod
)
197 self
.framework
.observe(self
.on
["keystone"].relation_broken
, self
.configure_pod
)
199 self
.http_server
= HttpServer(self
, "nbi")
200 self
.framework
.observe(self
.on
["nbi"].relation_joined
, self
._publish
_nbi
_info
)
202 def _publish_nbi_info(self
, event
):
203 """Publishes NBI information.
206 event (EventBase): RO relation event.
208 if self
.unit
.is_leader():
209 self
.http_server
.publish_info(self
.app
.name
, PORT
)
211 def _check_missing_dependencies(self
, config
: ConfigModel
):
212 missing_relations
= []
214 if not self
.kafka
.host
or not self
.kafka
.port
:
215 missing_relations
.append("kafka")
216 if not config
.mongodb_uri
and self
.mongodb_client
.is_missing_data_in_unit():
217 missing_relations
.append("mongodb")
218 if self
.prometheus_client
.is_missing_data_in_app():
219 missing_relations
.append("prometheus")
220 if config
.auth_backend
== "keystone":
221 if self
.keystone_client
.is_missing_data_in_app():
222 missing_relations
.append("keystone")
224 if missing_relations
:
225 raise RelationsMissing(missing_relations
)
227 def build_pod_spec(self
, image_info
):
229 config
= ConfigModel(**dict(self
.config
))
230 if config
.mongodb_uri
and not self
.mongodb_client
.is_missing_data_in_unit():
231 raise Exception("Mongodb data cannot be provided via config and relation")
234 self
._check
_missing
_dependencies
(config
)
236 security_context_enabled
= (
237 config
.security_context
if not config
.debug_mode
else False
240 # Create Builder for the PodSpec
241 pod_spec_builder
= PodSpecV3Builder(
242 enable_security_context
=security_context_enabled
245 # Add secrets to the pod
246 mongodb_secret_name
= f
"{self.app.name}-mongodb-secret"
247 pod_spec_builder
.add_secret(
250 "uri": config
.mongodb_uri
or self
.mongodb_client
.connection_string
,
251 "commonkey": config
.database_commonkey
,
255 # Build Init Container
256 pod_spec_builder
.add_init_container(
258 "name": "init-check",
259 "image": "alpine:latest",
263 f
"until (nc -zvw1 {self.kafka.host} {self.kafka.port} ); do sleep 3; done; exit 0",
269 container_builder
= ContainerV3Builder(
272 config
.image_pull_policy
,
273 run_as_non_root
=security_context_enabled
,
275 container_builder
.add_port(name
=self
.app
.name
, port
=PORT
)
276 container_builder
.add_tcpsocket_readiness_probe(
278 initial_delay_seconds
=config
.tcpsocket_readiness_probe
.get(
279 "initial_delay_seconds", 5
281 timeout_seconds
=config
.tcpsocket_readiness_probe
.get("timeout_seconds", 5),
282 period_seconds
=config
.tcpsocket_readiness_probe
.get("period_seconds", 10),
283 success_threshold
=config
.tcpsocket_readiness_probe
.get(
284 "success_threshold", 1
286 failure_threshold
=config
.tcpsocket_readiness_probe
.get(
287 "failure_threshold", 3
290 container_builder
.add_tcpsocket_liveness_probe(
292 initial_delay_seconds
=config
.tcpsocket_liveness_probe
.get(
293 "initial_delay_seconds", 45
295 timeout_seconds
=config
.tcpsocket_liveness_probe
.get("timeout_seconds", 10),
296 period_seconds
=config
.tcpsocket_liveness_probe
.get("period_seconds", 10),
297 success_threshold
=config
.tcpsocket_liveness_probe
.get(
298 "success_threshold", 1
300 failure_threshold
=config
.tcpsocket_liveness_probe
.get(
301 "failure_threshold", 3
304 container_builder
.add_envs(
306 # General configuration
307 "ALLOW_ANONYMOUS_LOGIN": "yes",
308 "OSMNBI_SERVER_ENABLE_TEST": config
.enable_test
,
309 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
310 # Kafka configuration
311 "OSMNBI_MESSAGE_HOST": self
.kafka
.host
,
312 "OSMNBI_MESSAGE_DRIVER": "kafka",
313 "OSMNBI_MESSAGE_PORT": self
.kafka
.port
,
314 # Database configuration
315 "OSMNBI_DATABASE_DRIVER": "mongo",
316 # Storage configuration
317 "OSMNBI_STORAGE_DRIVER": "mongo",
318 "OSMNBI_STORAGE_PATH": "/app/storage",
319 "OSMNBI_STORAGE_COLLECTION": "files",
320 # Prometheus configuration
321 "OSMNBI_PROMETHEUS_HOST": self
.prometheus_client
.hostname
,
322 "OSMNBI_PROMETHEUS_PORT": self
.prometheus_client
.port
,
324 "OSMNBI_LOG_LEVEL": config
.log_level
,
327 container_builder
.add_secret_envs(
328 secret_name
=mongodb_secret_name
,
330 "OSMNBI_DATABASE_URI": "uri",
331 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
332 "OSMNBI_STORAGE_URI": "uri",
335 if config
.auth_backend
== "internal":
336 container_builder
.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
337 elif config
.auth_backend
== "keystone":
338 keystone_secret_name
= f
"{self.app.name}-keystone-secret"
339 pod_spec_builder
.add_secret(
340 keystone_secret_name
,
342 "url": self
.keystone_client
.host
,
343 "port": self
.keystone_client
.port
,
344 "user_domain": self
.keystone_client
.user_domain_name
,
345 "project_domain": self
.keystone_client
.project_domain_name
,
346 "service_username": self
.keystone_client
.username
,
347 "service_password": self
.keystone_client
.password
,
348 "service_project": self
.keystone_client
.service
,
351 container_builder
.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
352 container_builder
.add_secret_envs(
353 secret_name
=keystone_secret_name
,
355 "OSMNBI_AUTHENTICATION_AUTH_URL": "url",
356 "OSMNBI_AUTHENTICATION_AUTH_PORT": "port",
357 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": "user_domain",
358 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": "project_domain",
359 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": "service_username",
360 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": "service_password",
361 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": "service_project",
364 container
= container_builder
.build()
366 # Add container to pod spec
367 pod_spec_builder
.add_container(container
)
369 # Add ingress resources to pod spec if site url exists
371 parsed
= urlparse(config
.site_url
)
373 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
374 str(config
.max_file_size
) + "m"
375 if config
.max_file_size
> 0
376 else config
.max_file_size
378 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
380 if config
.ingress_class
:
381 annotations
["kubernetes.io/ingress.class"] = config
.ingress_class
382 ingress_resource_builder
= IngressResourceV3Builder(
383 f
"{self.app.name}-ingress", annotations
386 if config
.ingress_whitelist_source_range
:
388 "nginx.ingress.kubernetes.io/whitelist-source-range"
389 ] = config
.ingress_whitelist_source_range
391 if config
.cluster_issuer
:
392 annotations
["cert-manager.io/cluster-issuer"] = config
.cluster_issuer
394 if parsed
.scheme
== "https":
395 ingress_resource_builder
.add_tls(
396 [parsed
.hostname
], config
.tls_secret_name
399 annotations
["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
401 ingress_resource_builder
.add_rule(parsed
.hostname
, self
.app
.name
, PORT
)
402 ingress_resource
= ingress_resource_builder
.build()
403 pod_spec_builder
.add_ingress_resource(ingress_resource
)
406 restart_policy
= PodRestartPolicy()
407 restart_policy
.add_secrets()
408 pod_spec_builder
.set_restart_policy(restart_policy
)
410 return pod_spec_builder
.build()
415 {"path": "/usr/lib/python3/dist-packages/osm_nbi"},
416 {"path": "/usr/lib/python3/dist-packages/osm_common"},
417 {"path": "/usr/lib/python3/dist-packages/osm_im"},
427 "module": "osm_nbi.nbi",
435 if __name__
== "__main__":