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