Make tcpsocket readiness and liveness configurable
[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 json
28 import logging
29 from typing import NoReturn, Optional
30 from urllib.parse import urlparse
31
32
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 (
41 ContainerV3Builder,
42 IngressResourceV3Builder,
43 PodRestartPolicy,
44 PodSpecV3Builder,
45 )
46 from opslib.osm.validator import ModelValidator, validator
47
48
49 logger = logging.getLogger(__name__)
50
51 PORT = 9999
52
53
54 class ConfigModel(ModelValidator):
55 enable_test: bool
56 auth_backend: str
57 database_commonkey: str
58 log_level: str
59 max_file_size: int
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
67 debug_mode: bool
68 security_context: bool
69 tcpsocket_liveness_probe: Optional[str]
70 tcpsocket_readiness_probe: Optional[str]
71
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'")
76 return v
77
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")
82 return v
83
84 @validator("max_file_size")
85 def validate_max_file_size(cls, v):
86 if v < 0:
87 raise ValueError("value must be equal or greater than 0")
88 return v
89
90 @validator("site_url")
91 def validate_site_url(cls, v):
92 if v:
93 parsed = urlparse(v)
94 if not parsed.scheme.startswith("http"):
95 raise ValueError("value must start with http")
96 return v
97
98 @validator("ingress_whitelist_source_range")
99 def validate_ingress_whitelist_source_range(cls, v):
100 if v:
101 ip_network(v)
102 return v
103
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")
108 return v
109
110 @validator("image_pull_policy")
111 def validate_image_pull_policy(cls, v):
112 values = {
113 "always": "Always",
114 "ifnotpresent": "IfNotPresent",
115 "never": "Never",
116 }
117 v = v.lower()
118 if v not in values.keys():
119 raise ValueError("value must be always, ifnotpresent or never")
120 return values[v]
121
122 @staticmethod
123 def _validate_tcpsocket_probe(probe_str) -> dict:
124 valid_attributes = (
125 "initial_delay_seconds",
126 "timeout_seconds",
127 "period_seconds",
128 "success_threshold",
129 "failure_threshold",
130 )
131 if probe_str:
132 probe_dict = json.loads(probe_str)
133 if all(attribute in valid_attributes for attribute in probe_dict):
134 return probe_dict
135 raise ValueError(
136 "One or more attributes are not accepted by the tcpsocket probe configuration"
137 )
138 return {}
139
140 @validator("tcpsocket_readiness_probe")
141 def validate_tcpsocket_readiness_probe(cls, v):
142 try:
143 return ConfigModel._validate_tcpsocket_probe(v)
144 except Exception as e:
145 raise ValueError(f"tcpsocket_readiness_probe configuration error: {e}")
146
147 @validator("tcpsocket_liveness_probe")
148 def validate_tcpsocket_liveness_probe(cls, v):
149 try:
150 return ConfigModel._validate_tcpsocket_probe(v)
151 except Exception as e:
152 raise ValueError(f"tcpsocket_liveness_probe configuration error: {e}")
153
154
155 class NbiCharm(CharmedOsmBase):
156 on = KafkaEvents()
157
158 def __init__(self, *args) -> NoReturn:
159 super().__init__(
160 *args,
161 oci_image="image",
162 vscode_workspace=VSCODE_WORKSPACE,
163 )
164 if self.config.get("debug_mode"):
165 self.enable_debug_mode(
166 pubkey=self.config.get("debug_pubkey"),
167 hostpaths={
168 "NBI": {
169 "hostpath": self.config.get("debug_nbi_local_path"),
170 "container-path": "/usr/lib/python3/dist-packages/osm_nbi",
171 },
172 "osm_common": {
173 "hostpath": self.config.get("debug_common_local_path"),
174 "container-path": "/usr/lib/python3/dist-packages/osm_common",
175 },
176 },
177 )
178
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)
182
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)
186
187 self.prometheus_client = PrometheusClient(self, "prometheus")
188 self.framework.observe(
189 self.on["prometheus"].relation_changed, self.configure_pod
190 )
191 self.framework.observe(
192 self.on["prometheus"].relation_broken, self.configure_pod
193 )
194
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)
198
199 self.http_server = HttpServer(self, "nbi")
200 self.framework.observe(self.on["nbi"].relation_joined, self._publish_nbi_info)
201
202 def _publish_nbi_info(self, event):
203 """Publishes NBI information.
204
205 Args:
206 event (EventBase): RO relation event.
207 """
208 if self.unit.is_leader():
209 self.http_server.publish_info(self.app.name, PORT)
210
211 def _check_missing_dependencies(self, config: ConfigModel):
212 missing_relations = []
213
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")
223
224 if missing_relations:
225 raise RelationsMissing(missing_relations)
226
227 def build_pod_spec(self, image_info):
228 # Validate config
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")
232
233 # Check relations
234 self._check_missing_dependencies(config)
235
236 security_context_enabled = (
237 config.security_context if not config.debug_mode else False
238 )
239
240 # Create Builder for the PodSpec
241 pod_spec_builder = PodSpecV3Builder(
242 enable_security_context=security_context_enabled
243 )
244
245 # Add secrets to the pod
246 mongodb_secret_name = f"{self.app.name}-mongodb-secret"
247 pod_spec_builder.add_secret(
248 mongodb_secret_name,
249 {
250 "uri": config.mongodb_uri or self.mongodb_client.connection_string,
251 "commonkey": config.database_commonkey,
252 },
253 )
254
255 # Build Init Container
256 pod_spec_builder.add_init_container(
257 {
258 "name": "init-check",
259 "image": "alpine:latest",
260 "command": [
261 "sh",
262 "-c",
263 f"until (nc -zvw1 {self.kafka.host} {self.kafka.port} ); do sleep 3; done; exit 0",
264 ],
265 }
266 )
267
268 # Build Container
269 container_builder = ContainerV3Builder(
270 self.app.name,
271 image_info,
272 config.image_pull_policy,
273 run_as_non_root=security_context_enabled,
274 )
275 container_builder.add_port(name=self.app.name, port=PORT)
276 container_builder.add_tcpsocket_readiness_probe(
277 PORT,
278 initial_delay_seconds=config.tcpsocket_readiness_probe.get(
279 "initial_delay_seconds", 5
280 ),
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
285 ),
286 failure_threshold=config.tcpsocket_readiness_probe.get(
287 "failure_threshold", 3
288 ),
289 )
290 container_builder.add_tcpsocket_liveness_probe(
291 PORT,
292 initial_delay_seconds=config.tcpsocket_liveness_probe.get(
293 "initial_delay_seconds", 45
294 ),
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
299 ),
300 failure_threshold=config.tcpsocket_liveness_probe.get(
301 "failure_threshold", 3
302 ),
303 )
304 container_builder.add_envs(
305 {
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,
323 # Log configuration
324 "OSMNBI_LOG_LEVEL": config.log_level,
325 }
326 )
327 container_builder.add_secret_envs(
328 secret_name=mongodb_secret_name,
329 envs={
330 "OSMNBI_DATABASE_URI": "uri",
331 "OSMNBI_DATABASE_COMMONKEY": "commonkey",
332 "OSMNBI_STORAGE_URI": "uri",
333 },
334 )
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,
341 {
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,
349 },
350 )
351 container_builder.add_env("OSMNBI_AUTHENTICATION_BACKEND", "keystone")
352 container_builder.add_secret_envs(
353 secret_name=keystone_secret_name,
354 envs={
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",
362 },
363 )
364 container = container_builder.build()
365
366 # Add container to pod spec
367 pod_spec_builder.add_container(container)
368
369 # Add ingress resources to pod spec if site url exists
370 if config.site_url:
371 parsed = urlparse(config.site_url)
372 annotations = {
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
377 ),
378 "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS",
379 }
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
384 )
385
386 if config.ingress_whitelist_source_range:
387 annotations[
388 "nginx.ingress.kubernetes.io/whitelist-source-range"
389 ] = config.ingress_whitelist_source_range
390
391 if config.cluster_issuer:
392 annotations["cert-manager.io/cluster-issuer"] = config.cluster_issuer
393
394 if parsed.scheme == "https":
395 ingress_resource_builder.add_tls(
396 [parsed.hostname], config.tls_secret_name
397 )
398 else:
399 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
400
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)
404
405 # Add restart policy
406 restart_policy = PodRestartPolicy()
407 restart_policy.add_secrets()
408 pod_spec_builder.set_restart_policy(restart_policy)
409
410 return pod_spec_builder.build()
411
412
413 VSCODE_WORKSPACE = {
414 "folders": [
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"},
418 ],
419 "settings": {},
420 "launch": {
421 "version": "0.2.0",
422 "configurations": [
423 {
424 "name": "NBI",
425 "type": "python",
426 "request": "launch",
427 "module": "osm_nbi.nbi",
428 "justMyCode": False,
429 }
430 ],
431 },
432 }
433
434
435 if __name__ == "__main__":
436 main(NbiCharm)