Integration of OSM Charms with new MongoDB
[osm/devops.git] / installers / charm / nbi / src / charm.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 # pylint: disable=E0213
24
25
26 from ipaddress import ip_network
27 import logging
28 from typing import NoReturn, Optional
29 from urllib.parse import urlparse
30
31
32 from charms.kafka_k8s.v0.kafka import KafkaEvents, KafkaRequires
33 from ops.main import main
34 from opslib.osm.charm import CharmedOsmBase, RelationsMissing
35 from opslib.osm.interfaces.http import HttpServer
36 from opslib.osm.interfaces.keystone import KeystoneClient
37 from opslib.osm.interfaces.mongo import MongoClient
38 from opslib.osm.interfaces.prometheus import PrometheusClient
39 from opslib.osm.pod import (
40 ContainerV3Builder,
41 IngressResourceV3Builder,
42 PodRestartPolicy,
43 PodSpecV3Builder,
44 )
45 from opslib.osm.validator import ModelValidator, validator
46
47
48 logger = logging.getLogger(__name__)
49
50 PORT = 9999
51
52
53 class ConfigModel(ModelValidator):
54 enable_test: bool
55 auth_backend: str
56 database_commonkey: str
57 log_level: str
58 max_file_size: int
59 site_url: Optional[str]
60 cluster_issuer: Optional[str]
61 ingress_class: Optional[str]
62 ingress_whitelist_source_range: Optional[str]
63 tls_secret_name: Optional[str]
64 mongodb_uri: Optional[str]
65 image_pull_policy: str
66 debug_mode: bool
67 security_context: bool
68
69 @validator("auth_backend")
70 def validate_auth_backend(cls, v):
71 if v not in {"internal", "keystone"}:
72 raise ValueError("value must be 'internal' or 'keystone'")
73 return v
74
75 @validator("log_level")
76 def validate_log_level(cls, v):
77 if v not in {"INFO", "DEBUG"}:
78 raise ValueError("value must be INFO or DEBUG")
79 return v
80
81 @validator("max_file_size")
82 def validate_max_file_size(cls, v):
83 if v < 0:
84 raise ValueError("value must be equal or greater than 0")
85 return v
86
87 @validator("site_url")
88 def validate_site_url(cls, v):
89 if v:
90 parsed = urlparse(v)
91 if not parsed.scheme.startswith("http"):
92 raise ValueError("value must start with http")
93 return v
94
95 @validator("ingress_whitelist_source_range")
96 def validate_ingress_whitelist_source_range(cls, v):
97 if v:
98 ip_network(v)
99 return v
100
101 @validator("mongodb_uri")
102 def validate_mongodb_uri(cls, v):
103 if v and not v.startswith("mongodb://"):
104 raise ValueError("mongodb_uri is not properly formed")
105 return v
106
107 @validator("image_pull_policy")
108 def validate_image_pull_policy(cls, v):
109 values = {
110 "always": "Always",
111 "ifnotpresent": "IfNotPresent",
112 "never": "Never",
113 }
114 v = v.lower()
115 if v not in values.keys():
116 raise ValueError("value must be always, ifnotpresent or never")
117 return values[v]
118
119
120 class NbiCharm(CharmedOsmBase):
121 on = KafkaEvents()
122
123 def __init__(self, *args) -> NoReturn:
124 super().__init__(
125 *args,
126 oci_image="image",
127 vscode_workspace=VSCODE_WORKSPACE,
128 )
129 if self.config.get("debug_mode"):
130 self.enable_debug_mode(
131 pubkey=self.config.get("debug_pubkey"),
132 hostpaths={
133 "NBI": {
134 "hostpath": self.config.get("debug_nbi_local_path"),
135 "container-path": "/usr/lib/python3/dist-packages/osm_nbi",
136 },
137 "osm_common": {
138 "hostpath": self.config.get("debug_common_local_path"),
139 "container-path": "/usr/lib/python3/dist-packages/osm_common",
140 },
141 },
142 )
143
144 self.kafka = KafkaRequires(self)
145 self.framework.observe(self.on.kafka_available, self.configure_pod)
146 self.framework.observe(self.on.kafka_broken, self.configure_pod)
147
148 self.mongodb_client = MongoClient(self, "mongodb")
149 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
150 self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
151
152 self.prometheus_client = PrometheusClient(self, "prometheus")
153 self.framework.observe(
154 self.on["prometheus"].relation_changed, self.configure_pod
155 )
156 self.framework.observe(
157 self.on["prometheus"].relation_broken, self.configure_pod
158 )
159
160 self.keystone_client = KeystoneClient(self, "keystone")
161 self.framework.observe(self.on["keystone"].relation_changed, self.configure_pod)
162 self.framework.observe(self.on["keystone"].relation_broken, self.configure_pod)
163
164 self.http_server = HttpServer(self, "nbi")
165 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
166
167 def _publish_nbi_info(self, event):
168 """Publishes NBI information.
169
170 Args:
171 event (EventBase): RO relation event.
172 """
173 if self.unit.is_leader():
174 self.http_server.publish_info(self.app.name, PORT)
175
176 def _check_missing_dependencies(self, config: ConfigModel):
177 missing_relations = []
178
179 if not self.kafka.host or not self.kafka.port:
180 missing_relations.append("kafka")
181 if not config.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
182 missing_relations.append("mongodb")
183 if self.prometheus_client.is_missing_data_in_app():
184 missing_relations.append("prometheus")
185 if config.auth_backend == "keystone":
186 if self.keystone_client.is_missing_data_in_app():
187 missing_relations.append("keystone")
188
189 if missing_relations:
190 raise RelationsMissing(missing_relations)
191
192 def build_pod_spec(self, image_info):
193 # Validate config
194 config = ConfigModel(**dict(self.config))
195
196 if config.mongodb_uri and not self.mongodb_client.is_missing_data_in_unit():
197 raise Exception("Mongodb data cannot be provided via config and relation")
198
199 # Check relations
200 self._check_missing_dependencies(config)
201
202 security_context_enabled = (
203 config.security_context if not config.debug_mode else False
204 )
205
206 # Create Builder for the PodSpec
207 pod_spec_builder = PodSpecV3Builder(
208 enable_security_context=security_context_enabled
209 )
210
211 # Add secrets to the pod
212 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
213 pod_spec_builder.add_secret(
214 mongodb_secret_name,
215 {
216 "uri": config.mongodb_uri or self.mongodb_client.connection_string,
217 "commonkey": config.database_commonkey,
218 },
219 )
220
221 # Build Init Container
222 pod_spec_builder.add_init_container(
223 {
224 "name": "init-check",
225 "image": "alpine:latest",
226 "command": [
227 "sh",
228 "-c",
229 f"until (nc -zvw1 {self.kafka.host} {self.kafka.port} ); do sleep 3; done; exit 0",
230 ],
231 }
232 )
233
234 # Build Container
235 container_builder = ContainerV3Builder(
236 self.app.name,
237 image_info,
238 config.image_pull_policy,
239 run_as_non_root=security_context_enabled,
240 )
241 container_builder.add_port(name=self.app.name, port=PORT)
242 container_builder.add_tcpsocket_readiness_probe(
243 PORT,
244 initial_delay_seconds=5,
245 timeout_seconds=5,
246 )
247 container_builder.add_tcpsocket_liveness_probe(
248 PORT,
249 initial_delay_seconds=45,
250 timeout_seconds=10,
251 )
252 container_builder.add_envs(
253 {
254 # General configuration
255 "ALLOW_ANONYMOUS_LOGIN": "yes",
256 "OSMNBI_SERVER_ENABLE_TEST": config.enable_test,
257 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
258 # Kafka configuration
259 "OSMNBI_MESSAGE_HOST": self.kafka.host,
260 "OSMNBI_MESSAGE_DRIVER": "kafka",
261 "OSMNBI_MESSAGE_PORT": self.kafka.port,
262 # Database configuration
263 "OSMNBI_DATABASE_DRIVER": "mongo",
264 # Storage configuration
265 "OSMNBI_STORAGE_DRIVER": "mongo",
266 "OSMNBI_STORAGE_PATH": "/app/storage",
267 "OSMNBI_STORAGE_COLLECTION": "files",
268 # Prometheus configuration
269 "OSMNBI_PROMETHEUS_HOST": self.prometheus_client.hostname,
270 "OSMNBI_PROMETHEUS_PORT": self.prometheus_client.port,
271 # Log configuration
272 "OSMNBI_LOG_LEVEL": config.log_level,
273 }
274 )
275 container_builder.add_secret_envs(
276 secret_name=mongodb_secret_name,
277 envs={
278 "OSMNBI_DATABASE_URI": "uri",
279 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
280 "OSMNBI_STORAGE_URI": "uri",
281 },
282 )
283 if config.auth_backend == "internal":
284 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
285 elif config.auth_backend == "keystone":
286 keystone_secret_name = f"{self.app.name}-keystone-secret"
287 pod_spec_builder.add_secret(
288 keystone_secret_name,
289 {
290 "url": self.keystone_client.host,
291 "port": self.keystone_client.port,
292 "user_domain": self.keystone_client.user_domain_name,
293 "project_domain": self.keystone_client.project_domain_name,
294 "service_username": self.keystone_client.username,
295 "service_password": self.keystone_client.password,
296 "service_project": self.keystone_client.service,
297 },
298 )
299 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
300 container_builder.add_secret_envs(
301 secret_name=keystone_secret_name,
302 envs={
303 "OSMNBI_AUTHENTICATION_AUTH_URL": "url",
304 "OSMNBI_AUTHENTICATION_AUTH_PORT": "port",
305 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": "user_domain",
306 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": "project_domain",
307 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": "service_username",
308 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": "service_password",
309 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": "service_project",
310 },
311 )
312 container = container_builder.build()
313
314 # Add container to pod spec
315 pod_spec_builder.add_container(container)
316
317 # Add ingress resources to pod spec if site url exists
318 if config.site_url:
319 parsed = urlparse(config.site_url)
320 annotations = {
321 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
322 str(config.max_file_size) + "m"
323 if config.max_file_size > 0
324 else config.max_file_size
325 ),
326 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
327 }
328 if config.ingress_class:
329 annotations["kubernetes.io/ingress.class"] = config.ingress_class
330 ingress_resource_builder = IngressResourceV3Builder(
331 f"{self.app.name}-ingress", annotations
332 )
333
334 if config.ingress_whitelist_source_range:
335 annotations[
336 "nginx.ingress.kubernetes.io/whitelist-source-range"
337 ] = config.ingress_whitelist_source_range
338
339 if config.cluster_issuer:
340 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
341
342 if parsed.scheme == "https":
343 ingress_resource_builder.add_tls(
344 [parsed.hostname], config.tls_secret_name
345 )
346 else:
347 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
348
349 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
350 ingress_resource = ingress_resource_builder.build()
351 pod_spec_builder.add_ingress_resource(ingress_resource)
352
353 # Add restart policy
354 restart_policy = PodRestartPolicy()
355 restart_policy.add_secrets()
356 pod_spec_builder.set_restart_policy(restart_policy)
357
358 return pod_spec_builder.build()
359
360
361 VSCODE_WORKSPACE = {
362 "folders": [
363 {"path": "/usr/lib/python3/dist-packages/osm_nbi"},
364 {"path": "/usr/lib/python3/dist-packages/osm_common"},
365 {"path": "/usr/lib/python3/dist-packages/osm_im"},
366 ],
367 "settings": {},
368 "launch": {
369 "version": "0.2.0",
370 "configurations": [
371 {
372 "name": "NBI",
373 "type": "python",
374 "request": "launch",
375 "module": "osm_nbi.nbi",
376 "justMyCode": False,
377 }
378 ],
379 },
380 }
381
382
383 if __name__ == "__main__":
384 main(NbiCharm)