d5c169b680ac829457d4c7d0c127827f42ced5a0
[osm/devops.git] / installers / charm / keystone / src / charm.py
1 #!/usr/bin/env python3
2 # Copyright 2020 Canonical Ltd.
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain 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,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15
16 import json
17 import logging
18 from datetime import datetime
19 from typing import (
20 Any,
21 Dict,
22 List,
23 NoReturn,
24 Tuple,
25 )
26 from urllib.parse import urlparse
27
28 from cryptography.fernet import Fernet
29
30 from ops.charm import CharmBase, EventBase, CharmEvents
31 from ops.framework import StoredState, EventSource
32 from ops.main import main
33 from ops.model import (
34 ActiveStatus,
35 BlockedStatus,
36 # MaintenanceStatus,
37 WaitingStatus,
38 # ModelError,
39 )
40
41 LOGGER = logging.getLogger(__name__)
42
43 REQUIRED_SETTINGS = ["token_expiration"]
44
45 # This is hardcoded in the keystone container script
46 DATABASE_NAME = "keystone"
47
48 # We expect the keystone container to use the default port
49 KEYSTONE_PORT = 5000
50
51 # Number of keys need might need to be adjusted in the future
52 NUMBER_FERNET_KEYS = 2
53 NUMBER_CREDENTIAL_KEYS = 2
54
55 # Path for keys
56 CREDENTIAL_KEYS_PATH = "/etc/keystone/credential-keys"
57 FERNET_KEYS_PATH = "/etc/keystone/fernet-keys"
58
59
60 class ConfigurePodEvent(EventBase):
61 """Configure Pod event"""
62
63 pass
64
65
66 class KeystoneEvents(CharmEvents):
67 """Keystone Events"""
68
69 configure_pod = EventSource(ConfigurePodEvent)
70
71
72 class KeystoneCharm(CharmBase):
73 """Keystone K8s Charm"""
74
75 state = StoredState()
76 on = KeystoneEvents()
77
78 def __init__(self, *args) -> NoReturn:
79 """Constructor of the Charm object.
80 Initializes internal state and register events it can handle.
81 """
82 super().__init__(*args)
83 self.state.set_default(db_host=None)
84 self.state.set_default(db_port=None)
85 self.state.set_default(db_user=None)
86 self.state.set_default(db_password=None)
87 self.state.set_default(pod_spec=None)
88 self.state.set_default(fernet_keys=None)
89 self.state.set_default(credential_keys=None)
90 self.state.set_default(keys_timestamp=0)
91
92 # Register all of the events we want to observe
93 self.framework.observe(self.on.config_changed, self.configure_pod)
94 self.framework.observe(self.on.start, self.configure_pod)
95 self.framework.observe(self.on.upgrade_charm, self.configure_pod)
96 self.framework.observe(self.on.leader_elected, self.configure_pod)
97 self.framework.observe(self.on.update_status, self.configure_pod)
98
99 # Registering custom internal events
100 self.framework.observe(self.on.configure_pod, self.configure_pod)
101
102 # Register relation events
103 self.framework.observe(
104 self.on.db_relation_changed, self._on_db_relation_changed
105 )
106 self.framework.observe(
107 self.on.db_relation_departed, self._on_db_relation_departed
108 )
109 self.framework.observe(
110 self.on.keystone_relation_joined, self._publish_keystone_info
111 )
112
113 def _publish_keystone_info(self, event: EventBase) -> NoReturn:
114 """Publishes keystone information for NBI usage through the keystone
115 relation.
116
117 Args:
118 event (EventBase): Keystone relation event to update NBI.
119 """
120 config = self.model.config
121 rel_data = {
122 "host": f"http://{self.app.name}:{KEYSTONE_PORT}/v3",
123 "port": str(KEYSTONE_PORT),
124 "keystone_db_password": config["keystone_db_password"],
125 "region_id": config["region_id"],
126 "user_domain_name": config["user_domain_name"],
127 "project_domain_name": config["project_domain_name"],
128 "admin_username": config["admin_username"],
129 "admin_password": config["admin_password"],
130 "admin_project_name": config["admin_project"],
131 "username": config["service_username"],
132 "password": config["service_password"],
133 "service": config["service_project"],
134 }
135 for k, v in rel_data.items():
136 event.relation.data[self.model.unit][k] = v
137
138 def _on_db_relation_changed(self, event: EventBase) -> NoReturn:
139 """Reads information about the DB relation, in order for keystone to
140 access it.
141
142 Args:
143 event (EventBase): DB relation event to access database
144 information.
145 """
146 if event.unit not in event.relation.data:
147 return
148 relation_data = event.relation.data[event.unit]
149 db_host = relation_data.get("host")
150 db_port = int(relation_data.get("port", 3306))
151 db_user = "root"
152 db_password = relation_data.get("root_password")
153
154 if (
155 db_host
156 and db_port
157 and db_user
158 and db_password
159 and (
160 self.state.db_host != db_host
161 or self.state.db_port != db_port
162 or self.state.db_user != db_user
163 or self.state.db_password != db_password
164 )
165 ):
166 self.state.db_host = db_host
167 self.state.db_port = db_port
168 self.state.db_user = db_user
169 self.state.db_password = db_password
170 self.on.configure_pod.emit()
171
172 def _on_db_relation_departed(self, event: EventBase) -> NoReturn:
173 """Clears data from db relation.
174
175 Args:
176 event (EventBase): DB relation event.
177
178 """
179 self.state.db_host = None
180 self.state.db_port = None
181 self.state.db_user = None
182 self.state.db_password = None
183 self.on.configure_pod.emit()
184
185 def _check_settings(self) -> str:
186 """Check if there any settings missing from Keystone configuration.
187
188 Returns:
189 str: Information about the problems found (if any).
190 """
191 problems = []
192 config = self.model.config
193
194 for setting in REQUIRED_SETTINGS:
195 if not config.get(setting):
196 problem = f"missing config {setting}"
197 problems.append(problem)
198
199 return ";".join(problems)
200
201 def _make_pod_image_details(self) -> Dict[str, str]:
202 """Generate the pod image details.
203
204 Returns:
205 Dict[str, str]: pod image details.
206 """
207 config = self.model.config
208 image_details = {
209 "imagePath": config["image"],
210 }
211 if config["image_username"]:
212 image_details.update(
213 {
214 "username": config["image_username"],
215 "password": config["image_password"],
216 }
217 )
218 return image_details
219
220 def _make_pod_ports(self) -> List[Dict[str, Any]]:
221 """Generate the pod ports details.
222
223 Returns:
224 List[Dict[str, Any]]: pod ports details.
225 """
226 return [
227 {"name": "keystone", "containerPort": KEYSTONE_PORT, "protocol": "TCP"},
228 ]
229
230 def _make_pod_envconfig(self) -> Dict[str, Any]:
231 """Generate pod environment configuraiton.
232
233 Returns:
234 Dict[str, Any]: pod environment configuration.
235 """
236 config = self.model.config
237
238 envconfig = {
239 "DB_HOST": self.state.db_host,
240 "DB_PORT": self.state.db_port,
241 "ROOT_DB_USER": self.state.db_user,
242 "ROOT_DB_PASSWORD": self.state.db_password,
243 "KEYSTONE_DB_PASSWORD": config["keystone_db_password"],
244 "REGION_ID": config["region_id"],
245 "KEYSTONE_HOST": self.app.name,
246 "ADMIN_USERNAME": config["admin_username"],
247 "ADMIN_PASSWORD": config["admin_password"],
248 "ADMIN_PROJECT": config["admin_project"],
249 "SERVICE_USERNAME": config["service_username"],
250 "SERVICE_PASSWORD": config["service_password"],
251 "SERVICE_PROJECT": config["service_project"],
252 }
253
254 if config.get("ldap_enabled"):
255 envconfig["LDAP_AUTHENTICATION_DOMAIN_NAME"] = config[
256 "ldap_authentication_domain_name"
257 ]
258 envconfig["LDAP_URL"] = config["ldap_url"]
259 envconfig["LDAP_PAGE_SIZE"] = config["ldap_page_size"]
260 envconfig["LDAP_USER_OBJECTCLASS"] = config["ldap_user_objectclass"]
261 envconfig["LDAP_USER_ID_ATTRIBUTE"] = config["ldap_user_id_attribute"]
262 envconfig["LDAP_USER_NAME_ATTRIBUTE"] = config["ldap_user_name_attribute"]
263 envconfig["LDAP_USER_PASS_ATTRIBUTE"] = config["ldap_user_pass_attribute"]
264 envconfig["LDAP_USER_ENABLED_MASK"] = config["ldap_user_enabled_mask"]
265 envconfig["LDAP_USER_ENABLED_DEFAULT"] = config["ldap_user_enabled_default"]
266 envconfig["LDAP_USER_ENABLED_INVERT"] = config["ldap_user_enabled_invert"]
267 envconfig["LDAP_GROUP_OBJECTCLASS"] = config["ldap_group_objectclass"]
268
269 if config["ldap_bind_user"]:
270 envconfig["LDAP_BIND_USER"] = config["ldap_bind_user"]
271
272 if config["ldap_bind_password"]:
273 envconfig["LDAP_BIND_PASSWORD"] = config["ldap_bind_password"]
274
275 if config["ldap_user_tree_dn"]:
276 envconfig["LDAP_USER_TREE_DN"] = config["ldap_user_tree_dn"]
277
278 if config["ldap_user_filter"]:
279 envconfig["LDAP_USER_FILTER"] = config["ldap_user_filter"]
280
281 if config["ldap_user_enabled_attribute"]:
282 envconfig["LDAP_USER_ENABLED_ATTRIBUTE"] = config[
283 "ldap_user_enabled_attribute"
284 ]
285
286 if config["ldap_chase_referrals"]:
287 envconfig["LDAP_CHASE_REFERRALS"] = config["ldap_chase_referrals"]
288
289 if config["ldap_group_tree_dn"]:
290 envconfig["LDAP_GROUP_TREE_DN"] = config["ldap_group_tree_dn"]
291
292 if config["ldap_use_starttls"]:
293 envconfig["LDAP_USE_STARTTLS"] = config["ldap_use_starttls"]
294 envconfig["LDAP_TLS_CACERT_BASE64"] = config["ldap_tls_cacert_base64"]
295 envconfig["LDAP_TLS_REQ_CERT"] = config["ldap_tls_req_cert"]
296
297 return envconfig
298
299 def _make_pod_ingress_resources(self) -> List[Dict[str, Any]]:
300 """Generate pod ingress resources.
301
302 Returns:
303 List[Dict[str, Any]]: pod ingress resources.
304 """
305 site_url = self.model.config["site_url"]
306
307 if not site_url:
308 return
309
310 parsed = urlparse(site_url)
311
312 if not parsed.scheme.startswith("http"):
313 return
314
315 max_file_size = self.model.config["max_file_size"]
316 ingress_whitelist_source_range = self.model.config[
317 "ingress_whitelist_source_range"
318 ]
319
320 annotations = {
321 "nginx.ingress.kubernetes.io/proxy-body-size": "{}m".format(max_file_size)
322 }
323
324 if ingress_whitelist_source_range:
325 annotations[
326 "nginx.ingress.kubernetes.io/whitelist-source-range"
327 ] = ingress_whitelist_source_range
328
329 ingress_spec_tls = None
330
331 if parsed.scheme == "https":
332 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
333 tls_secret_name = self.model.config["tls_secret_name"]
334 if tls_secret_name:
335 ingress_spec_tls[0]["secretName"] = tls_secret_name
336 else:
337 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
338
339 ingress = {
340 "name": "{}-ingress".format(self.app.name),
341 "annotations": annotations,
342 "spec": {
343 "rules": [
344 {
345 "host": parsed.hostname,
346 "http": {
347 "paths": [
348 {
349 "path": "/",
350 "backend": {
351 "serviceName": self.app.name,
352 "servicePort": KEYSTONE_PORT,
353 },
354 }
355 ]
356 },
357 }
358 ],
359 },
360 }
361 if ingress_spec_tls:
362 ingress["spec"]["tls"] = ingress_spec_tls
363
364 return [ingress]
365
366 def _generate_keys(self) -> Tuple[List[str], List[str]]:
367 """Generating new fernet tokens.
368
369 Returns:
370 Tuple[List[str], List[str]]: contains two lists of strings. First
371 list contains strings that represent
372 the keys for fernet and the second
373 list contains strins that represent
374 the keys for credentials.
375 """
376 fernet_keys = [
377 Fernet.generate_key().decode() for _ in range(NUMBER_FERNET_KEYS)
378 ]
379 credential_keys = [
380 Fernet.generate_key().decode() for _ in range(NUMBER_CREDENTIAL_KEYS)
381 ]
382
383 return (fernet_keys, credential_keys)
384
385 def _make_pod_files(
386 self, fernet_keys: List[str], credential_keys: List[str]
387 ) -> List[Dict[str, Any]]:
388 """Generating ConfigMap information.
389
390 Args:
391 fernet_keys (List[str]): keys for fernet.
392 credential_keys (List[str]): keys for credentials.
393
394 Returns:
395 List[Dict[str, Any]]: ConfigMap information.
396 """
397 files = [
398 {
399 "name": "fernet-keys",
400 "mountPath": FERNET_KEYS_PATH,
401 "files": [
402 {"path": str(key_id), "content": value}
403 for (key_id, value) in enumerate(fernet_keys)
404 ],
405 }
406 ]
407
408 files.append(
409 {
410 "name": "credential-keys",
411 "mountPath": CREDENTIAL_KEYS_PATH,
412 "files": [
413 {"path": str(key_id), "content": value}
414 for (key_id, value) in enumerate(credential_keys)
415 ],
416 }
417 )
418
419 return files
420
421 def configure_pod(self, event: EventBase) -> NoReturn:
422 """Assemble the pod spec and apply it, if possible.
423
424 Args:
425 event (EventBase): Hook or Relation event that started the
426 function.
427 """
428 if not self.state.db_host:
429 self.unit.status = WaitingStatus("Waiting for database relation")
430 event.defer()
431 return
432
433 if not self.unit.is_leader():
434 self.unit.status = ActiveStatus("ready")
435 return
436
437 if fernet_keys := self.state.fernet_keys:
438 fernet_keys = json.loads(fernet_keys)
439
440 if credential_keys := self.state.credential_keys:
441 credential_keys = json.loads(credential_keys)
442
443 now = datetime.now().timestamp()
444 keys_timestamp = self.state.keys_timestamp
445 token_expiration = self.model.config["token_expiration"]
446
447 valid_keys = (now - keys_timestamp) < token_expiration
448 if not credential_keys or not fernet_keys or not valid_keys:
449 fernet_keys, credential_keys = self._generate_keys()
450 self.state.fernet_keys = json.dumps(fernet_keys)
451 self.state.credential_keys = json.dumps(credential_keys)
452 self.state.keys_timestamp = now
453
454 # Check problems in the settings
455 problems = self._check_settings()
456 if problems:
457 self.unit.status = BlockedStatus(problems)
458 return
459
460 self.unit.status = BlockedStatus("Assembling pod spec")
461 image_details = self._make_pod_image_details()
462 ports = self._make_pod_ports()
463 env_config = self._make_pod_envconfig()
464 ingress_resources = self._make_pod_ingress_resources()
465 files = self._make_pod_files(fernet_keys, credential_keys)
466
467 pod_spec = {
468 "version": 3,
469 "containers": [
470 {
471 "name": self.framework.model.app.name,
472 "imageDetails": image_details,
473 "ports": ports,
474 "envConfig": env_config,
475 "volumeConfig": files,
476 }
477 ],
478 "kubernetesResources": {"ingressResources": ingress_resources or []},
479 }
480
481 if self.state.pod_spec != (
482 pod_spec_json := json.dumps(pod_spec, sort_keys=True)
483 ):
484 self.state.pod_spec = pod_spec_json
485 self.model.pod.set_spec(pod_spec)
486
487 self.unit.status = ActiveStatus("ready")
488
489
490 if __name__ == "__main__":
491 main(KeystoneCharm)