23dfcb6f5253a3533729c7ca332e1ff1c02b6478
[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
31 from ops.framework import StoredState
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 = []
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 KeystoneCharm(CharmBase):
61 """Keystone K8s Charm"""
62
63 state = StoredState()
64
65 def __init__(self, *args) -> NoReturn:
66 """Constructor of the Charm object.
67 Initializes internal state and register events it can handle.
68 """
69 super().__init__(*args)
70 self.state.set_default(db_host=None)
71 self.state.set_default(db_port=None)
72 self.state.set_default(db_user=None)
73 self.state.set_default(db_password=None)
74 self.state.set_default(pod_spec=None)
75 self.state.set_default(fernet_keys=None)
76 self.state.set_default(credential_keys=None)
77 self.state.set_default(keys_timestamp=0)
78
79 # Register all of the events we want to observe
80 self.framework.observe(self.on.config_changed, self.configure_pod)
81 self.framework.observe(self.on.start, self.configure_pod)
82 self.framework.observe(self.on.upgrade_charm, self.configure_pod)
83 self.framework.observe(self.on.leader_elected, self.configure_pod)
84 self.framework.observe(self.on.update_status, self.configure_pod)
85
86 # Register relation events
87 self.framework.observe(
88 self.on.db_relation_changed, self._on_db_relation_changed
89 )
90 self.framework.observe(
91 self.on.keystone_relation_joined, self._publish_keystone_info
92 )
93
94 def _publish_keystone_info(self, event: EventBase) -> NoReturn:
95 """Publishes keystone information for NBI usage through the keystone
96 relation.
97
98 Args:
99 event (EventBase): Keystone relation event to update NBI.
100 """
101 config = self.model.config
102 if self.unit.is_leader():
103 rel_data = {
104 "host": f"http://{self.app.name}:{KEYSTONE_PORT}/v3",
105 "port": str(KEYSTONE_PORT),
106 "keystone_db_password": config["keystone_db_password"],
107 "region_id": config["region_id"],
108 "user_domain_name": config["user_domain_name"],
109 "project_domain_name": config["project_domain_name"],
110 "admin_username": config["admin_username"],
111 "admin_password": config["admin_password"],
112 "admin_project_name": config["admin_project"],
113 "username": config["service_username"],
114 "password": config["service_password"],
115 "service": config["service_project"],
116 }
117 for k, v in rel_data.items():
118 event.relation.data[self.model.unit][k] = v
119
120 def _on_db_relation_changed(self, event: EventBase) -> NoReturn:
121 """Reads information about the DB relation, in order for keystone to
122 access it.
123
124 Args:
125 event (EventBase): DB relation event to access database
126 information.
127 """
128 self.state.db_host = event.relation.data[event.unit].get("host")
129 self.state.db_port = event.relation.data[event.unit].get("port", 3306)
130 self.state.db_user = "root" # event.relation.data[event.unit].get("user")
131 self.state.db_password = event.relation.data[event.unit].get("root_password")
132 if self.state.db_host:
133 self.configure_pod(event)
134
135 def _check_settings(self) -> str:
136 """Check if there any settings missing from Keystone configuration.
137
138 Returns:
139 str: Information about the problems found (if any).
140 """
141 problems = []
142 config = self.model.config
143
144 for setting in REQUIRED_SETTINGS:
145 if not config.get(setting):
146 problem = f"missing config {setting}"
147 problems.append(problem)
148
149 return ";".join(problems)
150
151 def _make_pod_image_details(self) -> Dict[str, str]:
152 """Generate the pod image details.
153
154 Returns:
155 Dict[str, str]: pod image details.
156 """
157 config = self.model.config
158 image_details = {
159 "imagePath": config["image"],
160 }
161 if config["image_username"]:
162 image_details.update(
163 {
164 "username": config["image_username"],
165 "password": config["image_password"],
166 }
167 )
168 return image_details
169
170 def _make_pod_ports(self) -> List[Dict[str, Any]]:
171 """Generate the pod ports details.
172
173 Returns:
174 List[Dict[str, Any]]: pod ports details.
175 """
176 return [
177 {"name": "keystone", "containerPort": KEYSTONE_PORT, "protocol": "TCP"},
178 ]
179
180 def _make_pod_envconfig(self) -> Dict[str, Any]:
181 """Generate pod environment configuraiton.
182
183 Returns:
184 Dict[str, Any]: pod environment configuration.
185 """
186 config = self.model.config
187
188 envconfig = {
189 "DB_HOST": self.state.db_host,
190 "DB_PORT": self.state.db_port,
191 "ROOT_DB_USER": self.state.db_user,
192 "ROOT_DB_PASSWORD": self.state.db_password,
193 "KEYSTONE_DB_PASSWORD": config["keystone_db_password"],
194 "REGION_ID": config["region_id"],
195 "KEYSTONE_HOST": self.app.name,
196 "ADMIN_USERNAME": config["admin_username"],
197 "ADMIN_PASSWORD": config["admin_password"],
198 "ADMIN_PROJECT": config["admin_project"],
199 "SERVICE_USERNAME": config["service_username"],
200 "SERVICE_PASSWORD": config["service_password"],
201 "SERVICE_PROJECT": config["service_project"],
202 }
203
204 if config.get("ldap_enabled"):
205 envconfig["LDAP_AUTHENTICATION_DOMAIN_NAME"] = config[
206 "ldap_authentication_domain_name"
207 ]
208 envconfig["LDAP_URL"] = config["ldap_url"]
209 envconfig["LDAP_USER_OBJECTCLASS"] = config["ldap_user_objectclass"]
210 envconfig["LDAP_USER_ID_ATTRIBUTE"] = config["ldap_user_id_attribute"]
211 envconfig["LDAP_USER_NAME_ATTRIBUTE"] = config["ldap_user_name_attribute"]
212 envconfig["LDAP_USER_PASS_ATTRIBUTE"] = config["ldap_user_pass_attribute"]
213 envconfig["LDAP_USER_ENABLED_MASK"] = config["ldap_user_enabled_mask"]
214 envconfig["LDAP_USER_ENABLED_DEFAULT"] = config["ldap_user_enabled_default"]
215 envconfig["LDAP_USER_ENABLED_INVERT"] = config["ldap_user_enabled_invert"]
216
217 if config["ldap_bind_user"]:
218 envconfig["LDAP_BIND_USER"] = config["ldap_bind_user"]
219
220 if config["ldap_bind_password"]:
221 envconfig["LDAP_BIND_PASSWORD"] = config["ldap_bind_password"]
222
223 if config["ldap_user_tree_dn"]:
224 envconfig["LDAP_USER_TREE_DN"] = config["ldap_user_tree_dn"]
225
226 if config["ldap_user_filter"]:
227 envconfig["LDAP_USER_FILTER"] = config["ldap_user_filter"]
228
229 if config["ldap_user_enabled_attribute"]:
230 envconfig["LDAP_USER_ENABLED_ATTRIBUTE"] = config[
231 "ldap_user_enabled_attribute"
232 ]
233
234 if config["ldap_use_starttls"]:
235 envconfig["LDAP_USE_STARTTLS"] = config["ldap_use_starttls"]
236 envconfig["LDAP_TLS_CACERT_BASE64"] = config["ldap_tls_cacert_base64"]
237 envconfig["LDAP_TLS_REQ_CERT"] = config["ldap_tls_req_cert"]
238
239 return envconfig
240
241 def _make_pod_ingress_resources(self) -> List[Dict[str, Any]]:
242 """Generate pod ingress resources.
243
244 Returns:
245 List[Dict[str, Any]]: pod ingress resources.
246 """
247 site_url = self.model.config["site_url"]
248
249 if not site_url:
250 return
251
252 parsed = urlparse(site_url)
253
254 if not parsed.scheme.startswith("http"):
255 return
256
257 max_file_size = self.model.config["max_file_size"]
258 ingress_whitelist_source_range = self.model.config[
259 "ingress_whitelist_source_range"
260 ]
261
262 annotations = {
263 "nginx.ingress.kubernetes.io/proxy-body-size": "{}m".format(max_file_size)
264 }
265
266 if ingress_whitelist_source_range:
267 annotations[
268 "nginx.ingress.kubernetes.io/whitelist-source-range"
269 ] = ingress_whitelist_source_range
270
271 ingress_spec_tls = None
272
273 if parsed.scheme == "https":
274 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
275 tls_secret_name = self.model.config["tls_secret_name"]
276 if tls_secret_name:
277 ingress_spec_tls[0]["secretName"] = tls_secret_name
278 else:
279 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
280
281 ingress = {
282 "name": "{}-ingress".format(self.app.name),
283 "annotations": annotations,
284 "spec": {
285 "rules": [
286 {
287 "host": parsed.hostname,
288 "http": {
289 "paths": [
290 {
291 "path": "/",
292 "backend": {
293 "serviceName": self.app.name,
294 "servicePort": KEYSTONE_PORT,
295 },
296 }
297 ]
298 },
299 }
300 ],
301 },
302 }
303 if ingress_spec_tls:
304 ingress["spec"]["tls"] = ingress_spec_tls
305
306 return [ingress]
307
308 def _generate_keys(self) -> Tuple[List[str], List[str]]:
309 """Generating new fernet tokens.
310
311 Returns:
312 Tuple[List[str], List[str]]: contains two lists of strings. First
313 list contains strings that represent
314 the keys for fernet and the second
315 list contains strins that represent
316 the keys for credentials.
317 """
318 fernet_keys = [
319 Fernet.generate_key().decode() for _ in range(NUMBER_FERNET_KEYS)
320 ]
321 credential_keys = [
322 Fernet.generate_key().decode() for _ in range(NUMBER_CREDENTIAL_KEYS)
323 ]
324
325 return (fernet_keys, credential_keys)
326
327 def _make_pod_files(
328 self, fernet_keys: List[str], credential_keys: List[str]
329 ) -> List[Dict[str, Any]]:
330 """Generating ConfigMap information.
331
332 Args:
333 fernet_keys (List[str]): keys for fernet.
334 credential_keys (List[str]): keys for credentials.
335
336 Returns:
337 List[Dict[str, Any]]: ConfigMap information.
338 """
339 files = [
340 {
341 "name": "fernet-keys",
342 "mountPath": FERNET_KEYS_PATH,
343 "files": [
344 {"path": str(key_id), "content": value}
345 for (key_id, value) in enumerate(fernet_keys)
346 ],
347 }
348 ]
349
350 files.append(
351 {
352 "name": "credential-keys",
353 "mountPath": CREDENTIAL_KEYS_PATH,
354 "files": [
355 {"path": str(key_id), "content": value}
356 for (key_id, value) in enumerate(credential_keys)
357 ],
358 }
359 )
360
361 return files
362
363 def configure_pod(self, event: EventBase) -> NoReturn:
364 """Assemble the pod spec and apply it, if possible.
365
366 Args:
367 event (EventBase): Hook or Relation event that started the
368 function.
369 """
370 if not self.state.db_host:
371 self.unit.status = WaitingStatus("Waiting for database relation")
372 event.defer()
373 return
374
375 if not self.unit.is_leader():
376 self.unit.status = ActiveStatus("ready")
377 return
378
379 if fernet_keys := self.state.fernet_keys:
380 fernet_keys = json.loads(fernet_keys)
381
382 if credential_keys := self.state.credential_keys:
383 credential_keys = json.loads(credential_keys)
384
385 now = datetime.now().timestamp()
386 keys_timestamp = self.state.keys_timestamp
387 token_expiration = self.model.config["token_expiration"]
388
389 valid_keys = (now - keys_timestamp) < token_expiration
390 if not credential_keys or not fernet_keys or not valid_keys:
391 fernet_keys, credential_keys = self._generate_keys()
392 self.state.fernet_keys = json.dumps(fernet_keys)
393 self.state.credential_keys = json.dumps(credential_keys)
394 self.state.keys_timestamp = now
395
396 # Check problems in the settings
397 problems = self._check_settings()
398 if problems:
399 self.unit.status = BlockedStatus(problems)
400 return
401
402 self.unit.status = BlockedStatus("Assembling pod spec")
403 image_details = self._make_pod_image_details()
404 ports = self._make_pod_ports()
405 env_config = self._make_pod_envconfig()
406 ingress_resources = self._make_pod_ingress_resources()
407 files = self._make_pod_files(fernet_keys, credential_keys)
408
409 pod_spec = {
410 "version": 3,
411 "containers": [
412 {
413 "name": self.framework.model.app.name,
414 "imageDetails": image_details,
415 "ports": ports,
416 "envConfig": env_config,
417 "volumeConfig": files,
418 }
419 ],
420 "kubernetesResources": {"ingressResources": ingress_resources or []},
421 }
422
423 if self.state.pod_spec != (
424 pod_spec_json := json.dumps(pod_spec, sort_keys=True)
425 ):
426 self.state.pod_spec = pod_spec_json
427 self.model.pod.set_spec(pod_spec)
428
429 self.unit.status = ActiveStatus("ready")
430
431
432 if __name__ == "__main__":
433 main(KeystoneCharm)