faba78ab2993089d33af9a6f16fbe4212ff7a3f8
[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 ops.main import main
33 from opslib.osm.charm import CharmedOsmBase, RelationsMissing
34 from opslib.osm.interfaces.http import HttpServer
35 from opslib.osm.interfaces.kafka import KafkaClient
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 def __init__(self, *args) -> NoReturn:
122 super().__init__(
123 *args,
124 oci_image="image",
125 vscode_workspace=VSCODE_WORKSPACE,
126 )
127 if self.config.get("debug_mode"):
128 self.enable_debug_mode(
129 pubkey=self.config.get("debug_pubkey"),
130 hostpaths={
131 "NBI": {
132 "hostpath": self.config.get("debug_nbi_local_path"),
133 "container-path": "/usr/lib/python3/dist-packages/osm_nbi",
134 },
135 "osm_common": {
136 "hostpath": self.config.get("debug_common_local_path"),
137 "container-path": "/usr/lib/python3/dist-packages/osm_common",
138 },
139 },
140 )
141
142 self.kafka_client = KafkaClient(self, "kafka")
143 self.framework.observe(self.on["kafka"].relation_changed, self.configure_pod)
144 self.framework.observe(self.on["kafka"].relation_broken, self.configure_pod)
145
146 self.mongodb_client = MongoClient(self, "mongodb")
147 self.framework.observe(self.on["mongodb"].relation_changed, self.configure_pod)
148 self.framework.observe(self.on["mongodb"].relation_broken, self.configure_pod)
149
150 self.prometheus_client = PrometheusClient(self, "prometheus")
151 self.framework.observe(
152 self.on["prometheus"].relation_changed, self.configure_pod
153 )
154 self.framework.observe(
155 self.on["prometheus"].relation_broken, self.configure_pod
156 )
157
158 self.keystone_client = KeystoneClient(self, "keystone")
159 self.framework.observe(self.on["keystone"].relation_changed, self.configure_pod)
160 self.framework.observe(self.on["keystone"].relation_broken, self.configure_pod)
161
162 self.http_server = HttpServer(self, "nbi")
163 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
164
165 def _publish_nbi_info(self, event):
166 """Publishes NBI information.
167
168 Args:
169 event (EventBase): RO relation event.
170 """
171 if self.unit.is_leader():
172 self.http_server.publish_info(self.app.name, PORT)
173
174 def _check_missing_dependencies(self, config: ConfigModel):
175 missing_relations = []
176
177 if (
178 self.kafka_client.is_missing_data_in_unit()
179 and self.kafka_client.is_missing_data_in_app()
180 ):
181 missing_relations.append("kafka")
182 if not config.mongodb_uri and self.mongodb_client.is_missing_data_in_unit():
183 missing_relations.append("mongodb")
184 if self.prometheus_client.is_missing_data_in_app():
185 missing_relations.append("prometheus")
186 if config.auth_backend == "keystone":
187 if self.keystone_client.is_missing_data_in_app():
188 missing_relations.append("keystone")
189
190 if missing_relations:
191 raise RelationsMissing(missing_relations)
192
193 def build_pod_spec(self, image_info):
194 # Validate config
195 config = ConfigModel(**dict(self.config))
196
197 if config.mongodb_uri and not self.mongodb_client.is_missing_data_in_unit():
198 raise Exception("Mongodb data cannot be provided via config and relation")
199
200 # Check relations
201 self._check_missing_dependencies(config)
202
203 security_context_enabled = (
204 config.security_context if not config.debug_mode else False
205 )
206
207 # Create Builder for the PodSpec
208 pod_spec_builder = PodSpecV3Builder(
209 enable_security_context=security_context_enabled
210 )
211
212 # Add secrets to the pod
213 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
214 pod_spec_builder.add_secret(
215 mongodb_secret_name,
216 {
217 "uri": config.mongodb_uri or self.mongodb_client.connection_string,
218 "commonkey": config.database_commonkey,
219 },
220 )
221
222 # Build Init Container
223 pod_spec_builder.add_init_container(
224 {
225 "name": "init-check",
226 "image": "alpine:latest",
227 "command": [
228 "sh",
229 "-c",
230 f"until (nc -zvw1 {self.kafka_client.host} {self.kafka_client.port} ); do sleep 3; done; exit 0",
231 ],
232 }
233 )
234
235 # Build Container
236 container_builder = ContainerV3Builder(
237 self.app.name,
238 image_info,
239 config.image_pull_policy,
240 run_as_non_root=security_context_enabled,
241 )
242 container_builder.add_port(name=self.app.name, port=PORT)
243 container_builder.add_tcpsocket_readiness_probe(
244 PORT,
245 initial_delay_seconds=5,
246 timeout_seconds=5,
247 )
248 container_builder.add_tcpsocket_liveness_probe(
249 PORT,
250 initial_delay_seconds=45,
251 timeout_seconds=10,
252 )
253 container_builder.add_envs(
254 {
255 # General configuration
256 "ALLOW_ANONYMOUS_LOGIN": "yes",
257 "OSMNBI_SERVER_ENABLE_TEST": config.enable_test,
258 "OSMNBI_STATIC_DIR": "/app/osm_nbi/html_public",
259 # Kafka configuration
260 "OSMNBI_MESSAGE_HOST": self.kafka_client.host,
261 "OSMNBI_MESSAGE_DRIVER": "kafka",
262 "OSMNBI_MESSAGE_PORT": self.kafka_client.port,
263 # Database configuration
264 "OSMNBI_DATABASE_DRIVER": "mongo",
265 # Storage configuration
266 "OSMNBI_STORAGE_DRIVER": "mongo",
267 "OSMNBI_STORAGE_PATH": "/app/storage",
268 "OSMNBI_STORAGE_COLLECTION": "files",
269 # Prometheus configuration
270 "OSMNBI_PROMETHEUS_HOST": self.prometheus_client.hostname,
271 "OSMNBI_PROMETHEUS_PORT": self.prometheus_client.port,
272 # Log configuration
273 "OSMNBI_LOG_LEVEL": config.log_level,
274 }
275 )
276 container_builder.add_secret_envs(
277 secret_name=mongodb_secret_name,
278 envs={
279 "OSMNBI_DATABASE_URI": "uri",
280 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
281 "OSMNBI_STORAGE_URI": "uri",
282 },
283 )
284 if config.auth_backend == "internal":
285 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "internal")
286 elif config.auth_backend == "keystone":
287 keystone_secret_name = f"{self.app.name}-keystone-secret"
288 pod_spec_builder.add_secret(
289 keystone_secret_name,
290 {
291 "url": self.keystone_client.host,
292 "port": self.keystone_client.port,
293 "user_domain": self.keystone_client.user_domain_name,
294 "project_domain": self.keystone_client.project_domain_name,
295 "service_username": self.keystone_client.username,
296 "service_password": self.keystone_client.password,
297 "service_project": self.keystone_client.service,
298 },
299 )
300 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
301 container_builder.add_secret_envs(
302 secret_name=keystone_secret_name,
303 envs={
304 "OSMNBI_AUTHENTICATION_AUTH_URL": "url",
305 "OSMNBI_AUTHENTICATION_AUTH_PORT": "port",
306 "OSMNBI_AUTHENTICATION_USER_DOMAIN_NAME": "user_domain",
307 "OSMNBI_AUTHENTICATION_PROJECT_DOMAIN_NAME": "project_domain",
308 "OSMNBI_AUTHENTICATION_SERVICE_USERNAME": "service_username",
309 "OSMNBI_AUTHENTICATION_SERVICE_PASSWORD": "service_password",
310 "OSMNBI_AUTHENTICATION_SERVICE_PROJECT": "service_project",
311 },
312 )
313 container = container_builder.build()
314
315 # Add container to pod spec
316 pod_spec_builder.add_container(container)
317
318 # Add ingress resources to pod spec if site url exists
319 if config.site_url:
320 parsed = urlparse(config.site_url)
321 annotations = {
322 "nginx.ingress.kubernetes.io/proxy-body-size": "{}".format(
323 str(config.max_file_size) + "m"
324 if config.max_file_size > 0
325 else config.max_file_size
326 ),
327 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
328 }
329 if config.ingress_class:
330 annotations["kubernetes.io/ingress.class"] = config.ingress_class
331 ingress_resource_builder = IngressResourceV3Builder(
332 f"{self.app.name}-ingress", annotations
333 )
334
335 if config.ingress_whitelist_source_range:
336 annotations[
337 "nginx.ingress.kubernetes.io/whitelist-source-range"
338 ] = config.ingress_whitelist_source_range
339
340 if config.cluster_issuer:
341 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
342
343 if parsed.scheme == "https":
344 ingress_resource_builder.add_tls(
345 [parsed.hostname], config.tls_secret_name
346 )
347 else:
348 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
349
350 ingress_resource_builder.add_rule(parsed.hostname, self.app.name, PORT)
351 ingress_resource = ingress_resource_builder.build()
352 pod_spec_builder.add_ingress_resource(ingress_resource)
353
354 # Add restart policy
355 restart_policy = PodRestartPolicy()
356 restart_policy.add_secrets()
357 pod_spec_builder.set_restart_policy(restart_policy)
358
359 return pod_spec_builder.build()
360
361
362 VSCODE_WORKSPACE = {
363 "folders": [
364 {"path": "/usr/lib/python3/dist-packages/osm_nbi"},
365 {"path": "/usr/lib/python3/dist-packages/osm_common"},
366 {"path": "/usr/lib/python3/dist-packages/osm_im"},
367 ],
368 "settings": {},
369 "launch": {
370 "version": "0.2.0",
371 "configurations": [
372 {
373 "name": "NBI",
374 "type": "python",
375 "request": "launch",
376 "module": "osm_nbi.nbi",
377 "justMyCode": False,
378 }
379 ],
380 },
381 }
382
383
384 if __name__ == "__main__":
385 main(NbiCharm)