blob: 637461e35f1964e14ca03bab8136db01788e07b9 [file] [log] [blame]
David Garcia009a5d62020-08-27 16:53:44 +02001#!/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
sousaedu738bf6f2020-10-10 00:25:26 +010016import json
David Garcia009a5d62020-08-27 16:53:44 +020017import logging
sousaedu738bf6f2020-10-10 00:25:26 +010018from datetime import datetime
19from typing import (
20 Any,
21 Dict,
22 List,
23 NoReturn,
24 Tuple,
25)
David Garcia009a5d62020-08-27 16:53:44 +020026from urllib.parse import urlparse
27
sousaedu738bf6f2020-10-10 00:25:26 +010028from cryptography.fernet import Fernet
David Garcia009a5d62020-08-27 16:53:44 +020029
David Garciaab11f842020-12-16 17:25:15 +010030from ops.charm import CharmBase, EventBase, CharmEvents
31from ops.framework import StoredState, EventSource
David Garcia009a5d62020-08-27 16:53:44 +020032from ops.main import main
33from ops.model import (
34 ActiveStatus,
35 BlockedStatus,
36 # MaintenanceStatus,
37 WaitingStatus,
38 # ModelError,
39)
David Garcia009a5d62020-08-27 16:53:44 +020040
sousaedu738bf6f2020-10-10 00:25:26 +010041LOGGER = logging.getLogger(__name__)
David Garcia009a5d62020-08-27 16:53:44 +020042
David Garciaab11f842020-12-16 17:25:15 +010043REQUIRED_SETTINGS = ["token_expiration"]
David Garcia009a5d62020-08-27 16:53:44 +020044
sousaedu738bf6f2020-10-10 00:25:26 +010045# This is hardcoded in the keystone container script
46DATABASE_NAME = "keystone"
47
David Garcia009a5d62020-08-27 16:53:44 +020048# We expect the keystone container to use the default port
49KEYSTONE_PORT = 5000
50
sousaedu738bf6f2020-10-10 00:25:26 +010051# Number of keys need might need to be adjusted in the future
52NUMBER_FERNET_KEYS = 2
53NUMBER_CREDENTIAL_KEYS = 2
54
55# Path for keys
56CREDENTIAL_KEYS_PATH = "/etc/keystone/credential-keys"
57FERNET_KEYS_PATH = "/etc/keystone/fernet-keys"
58
David Garcia009a5d62020-08-27 16:53:44 +020059
David Garciaab11f842020-12-16 17:25:15 +010060class ConfigurePodEvent(EventBase):
61 """Configure Pod event"""
62
63 pass
64
65
66class KeystoneEvents(CharmEvents):
67 """Keystone Events"""
68
69 configure_pod = EventSource(ConfigurePodEvent)
70
David Garcia009a5d62020-08-27 16:53:44 +020071class KeystoneCharm(CharmBase):
sousaedu738bf6f2020-10-10 00:25:26 +010072 """Keystone K8s Charm"""
David Garcia009a5d62020-08-27 16:53:44 +020073
74 state = StoredState()
David Garciaab11f842020-12-16 17:25:15 +010075 on = KeystoneEvents()
David Garcia009a5d62020-08-27 16:53:44 +020076
sousaedu738bf6f2020-10-10 00:25:26 +010077 def __init__(self, *args) -> NoReturn:
78 """Constructor of the Charm object.
79 Initializes internal state and register events it can handle.
80 """
David Garcia009a5d62020-08-27 16:53:44 +020081 super().__init__(*args)
sousaedu738bf6f2020-10-10 00:25:26 +010082 self.state.set_default(db_host=None)
83 self.state.set_default(db_port=None)
84 self.state.set_default(db_user=None)
85 self.state.set_default(db_password=None)
86 self.state.set_default(pod_spec=None)
87 self.state.set_default(fernet_keys=None)
88 self.state.set_default(credential_keys=None)
89 self.state.set_default(keys_timestamp=0)
David Garcia009a5d62020-08-27 16:53:44 +020090
91 # Register all of the events we want to observe
92 self.framework.observe(self.on.config_changed, self.configure_pod)
93 self.framework.observe(self.on.start, self.configure_pod)
94 self.framework.observe(self.on.upgrade_charm, self.configure_pod)
sousaedu738bf6f2020-10-10 00:25:26 +010095 self.framework.observe(self.on.leader_elected, self.configure_pod)
96 self.framework.observe(self.on.update_status, self.configure_pod)
David Garcia009a5d62020-08-27 16:53:44 +020097
David Garciaab11f842020-12-16 17:25:15 +010098 # Registering custom internal events
99 self.framework.observe(self.on.configure_pod, self.configure_pod)
100
David Garcia009a5d62020-08-27 16:53:44 +0200101 # Register relation events
David Garcia009a5d62020-08-27 16:53:44 +0200102 self.framework.observe(
103 self.on.db_relation_changed, self._on_db_relation_changed
104 )
105 self.framework.observe(
David Garciaab11f842020-12-16 17:25:15 +0100106 self.on.db_relation_departed, self._on_db_relation_departed
107 )
108 self.framework.observe(
David Garcia009a5d62020-08-27 16:53:44 +0200109 self.on.keystone_relation_joined, self._publish_keystone_info
110 )
111
sousaedu738bf6f2020-10-10 00:25:26 +0100112 def _publish_keystone_info(self, event: EventBase) -> NoReturn:
113 """Publishes keystone information for NBI usage through the keystone
114 relation.
115
116 Args:
117 event (EventBase): Keystone relation event to update NBI.
118 """
David Garcia009a5d62020-08-27 16:53:44 +0200119 config = self.model.config
David Garciaab11f842020-12-16 17:25:15 +0100120 rel_data = {
121 "host": f"http://{self.app.name}:{KEYSTONE_PORT}/v3",
122 "port": str(KEYSTONE_PORT),
123 "keystone_db_password": config["keystone_db_password"],
124 "region_id": config["region_id"],
125 "user_domain_name": config["user_domain_name"],
126 "project_domain_name": config["project_domain_name"],
127 "admin_username": config["admin_username"],
128 "admin_password": config["admin_password"],
129 "admin_project_name": config["admin_project"],
130 "username": config["service_username"],
131 "password": config["service_password"],
132 "service": config["service_project"],
133 }
134 for k, v in rel_data.items():
135 event.relation.data[self.model.unit][k] = v
David Garcia009a5d62020-08-27 16:53:44 +0200136
sousaedu738bf6f2020-10-10 00:25:26 +0100137 def _on_db_relation_changed(self, event: EventBase) -> NoReturn:
138 """Reads information about the DB relation, in order for keystone to
139 access it.
140
141 Args:
142 event (EventBase): DB relation event to access database
143 information.
144 """
David Garciaab11f842020-12-16 17:25:15 +0100145 if not event.unit in event.relation.data:
146 return
147 relation_data = event.relation.data[event.unit]
148 db_host = relation_data.get("host")
149 db_port = int(relation_data.get("port", 3306))
150 db_user = "root"
151 db_password = relation_data.get("root_password")
152
153 if (
154 db_host
155 and db_port
156 and db_user
157 and db_password
158 and (
159 self.state.db_host != db_host
160 or self.state.db_port != db_port
161 or self.state.db_user != db_user
162 or self.state.db_password != db_password
163 )
164 ):
165 self.state.db_host = db_host
166 self.state.db_port = db_port
167 self.state.db_user = db_user
168 self.state.db_password = db_password
169 self.on.configure_pod.emit()
170
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()
David Garcia009a5d62020-08-27 16:53:44 +0200184
sousaedu738bf6f2020-10-10 00:25:26 +0100185 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200191 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
sousaedu738bf6f2020-10-10 00:25:26 +0100201 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200207 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
sousaedu738bf6f2020-10-10 00:25:26 +0100220 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200226 return [
227 {"name": "keystone", "containerPort": KEYSTONE_PORT, "protocol": "TCP"},
228 ]
229
sousaedu738bf6f2020-10-10 00:25:26 +0100230 def _make_pod_envconfig(self) -> Dict[str, Any]:
231 """Generate pod environment configuraiton.
232
233 Returns:
234 Dict[str, Any]: pod environment configuration.
235 """
David Garcia009a5d62020-08-27 16:53:44 +0200236 config = self.model.config
237
sousaedu126a4432020-09-23 13:28:25 +0100238 envconfig = {
David Garcia009a5d62020-08-27 16:53:44 +0200239 "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
sousaedu126a4432020-09-23 13:28:25 +0100254 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"]
sousaedu0be373d2020-10-20 01:06:32 +0100259 envconfig["LDAP_PAGE_SIZE"] = config["ldap_page_size"]
sousaedu126a4432020-09-23 13:28:25 +0100260 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"]
sousaedu0be373d2020-10-20 01:06:32 +0100267 envconfig["LDAP_GROUP_OBJECTCLASS"] = config["ldap_group_objectclass"]
sousaedu126a4432020-09-23 13:28:25 +0100268
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
sousaedu0be373d2020-10-20 01:06:32 +0100286 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
sousaedu126a4432020-09-23 13:28:25 +0100292 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
sousaedu738bf6f2020-10-10 00:25:26 +0100299 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200305 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
sousaedu738bf6f2020-10-10 00:25:26 +0100366 def _generate_keys(self) -> Tuple[List[str], List[str]]:
367 """Generating new fernet tokens.
David Garcia009a5d62020-08-27 16:53:44 +0200368
sousaedu738bf6f2020-10-10 00:25:26 +0100369 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200428 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():
sousaedu738bf6f2020-10-10 00:25:26 +0100434 self.unit.status = ActiveStatus("ready")
David Garcia009a5d62020-08-27 16:53:44 +0200435 return
436
sousaedu738bf6f2020-10-10 00:25:26 +0100437 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
David Garcia009a5d62020-08-27 16:53:44 +0200454 # 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()
sousaedu738bf6f2020-10-10 00:25:26 +0100465 files = self._make_pod_files(fernet_keys, credential_keys)
David Garcia009a5d62020-08-27 16:53:44 +0200466
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,
sousaedu738bf6f2020-10-10 00:25:26 +0100475 "volumeConfig": files,
David Garcia009a5d62020-08-27 16:53:44 +0200476 }
477 ],
478 "kubernetesResources": {"ingressResources": ingress_resources or []},
479 }
sousaedu738bf6f2020-10-10 00:25:26 +0100480
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")
David Garcia009a5d62020-08-27 16:53:44 +0200488
489
490if __name__ == "__main__":
491 main(KeystoneCharm)