blob: ef16690a743eacc29eed57ff2a05f32dc2d5567d [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
sousaedu738bf6f2020-10-10 00:25:26 +010030from ops.charm import CharmBase, EventBase
31from ops.framework import StoredState
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
43REQUIRED_SETTINGS = []
44
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
60class KeystoneCharm(CharmBase):
sousaedu738bf6f2020-10-10 00:25:26 +010061 """Keystone K8s Charm"""
David Garcia009a5d62020-08-27 16:53:44 +020062
63 state = StoredState()
64
sousaedu738bf6f2020-10-10 00:25:26 +010065 def __init__(self, *args) -> NoReturn:
66 """Constructor of the Charm object.
67 Initializes internal state and register events it can handle.
68 """
David Garcia009a5d62020-08-27 16:53:44 +020069 super().__init__(*args)
sousaedu738bf6f2020-10-10 00:25:26 +010070 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)
David Garcia009a5d62020-08-27 16:53:44 +020078
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)
sousaedu738bf6f2020-10-10 00:25:26 +010083 self.framework.observe(self.on.leader_elected, self.configure_pod)
84 self.framework.observe(self.on.update_status, self.configure_pod)
David Garcia009a5d62020-08-27 16:53:44 +020085
86 # Register relation events
David Garcia009a5d62020-08-27 16:53:44 +020087 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
sousaedu738bf6f2020-10-10 00:25:26 +010094 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200101 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
sousaedu738bf6f2020-10-10 00:25:26 +0100120 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200128 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
sousaedu738bf6f2020-10-10 00:25:26 +0100135 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200141 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
sousaedu738bf6f2020-10-10 00:25:26 +0100151 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200157 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
sousaedu738bf6f2020-10-10 00:25:26 +0100170 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 """
David Garcia009a5d62020-08-27 16:53:44 +0200176 return [
177 {"name": "keystone", "containerPort": KEYSTONE_PORT, "protocol": "TCP"},
178 ]
179
sousaedu738bf6f2020-10-10 00:25:26 +0100180 def _make_pod_envconfig(self) -> Dict[str, Any]:
181 """Generate pod environment configuraiton.
182
183 Returns:
184 Dict[str, Any]: pod environment configuration.
185 """
David Garcia009a5d62020-08-27 16:53:44 +0200186 config = self.model.config
187
sousaedu126a4432020-09-23 13:28:25 +0100188 envconfig = {
David Garcia009a5d62020-08-27 16:53:44 +0200189 "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
sousaedu126a4432020-09-23 13:28:25 +0100204 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"]
sousaedu0be373d2020-10-20 01:06:32 +0100209 envconfig["LDAP_PAGE_SIZE"] = config["ldap_page_size"]
sousaedu126a4432020-09-23 13:28:25 +0100210 envconfig["LDAP_USER_OBJECTCLASS"] = config["ldap_user_objectclass"]
211 envconfig["LDAP_USER_ID_ATTRIBUTE"] = config["ldap_user_id_attribute"]
212 envconfig["LDAP_USER_NAME_ATTRIBUTE"] = config["ldap_user_name_attribute"]
213 envconfig["LDAP_USER_PASS_ATTRIBUTE"] = config["ldap_user_pass_attribute"]
214 envconfig["LDAP_USER_ENABLED_MASK"] = config["ldap_user_enabled_mask"]
215 envconfig["LDAP_USER_ENABLED_DEFAULT"] = config["ldap_user_enabled_default"]
216 envconfig["LDAP_USER_ENABLED_INVERT"] = config["ldap_user_enabled_invert"]
sousaedu0be373d2020-10-20 01:06:32 +0100217 envconfig["LDAP_GROUP_OBJECTCLASS"] = config["ldap_group_objectclass"]
sousaedu126a4432020-09-23 13:28:25 +0100218
219 if config["ldap_bind_user"]:
220 envconfig["LDAP_BIND_USER"] = config["ldap_bind_user"]
221
222 if config["ldap_bind_password"]:
223 envconfig["LDAP_BIND_PASSWORD"] = config["ldap_bind_password"]
224
225 if config["ldap_user_tree_dn"]:
226 envconfig["LDAP_USER_TREE_DN"] = config["ldap_user_tree_dn"]
227
228 if config["ldap_user_filter"]:
229 envconfig["LDAP_USER_FILTER"] = config["ldap_user_filter"]
230
231 if config["ldap_user_enabled_attribute"]:
232 envconfig["LDAP_USER_ENABLED_ATTRIBUTE"] = config[
233 "ldap_user_enabled_attribute"
234 ]
235
sousaedu0be373d2020-10-20 01:06:32 +0100236 if config["ldap_chase_referrals"]:
237 envconfig["LDAP_CHASE_REFERRALS"] = config["ldap_chase_referrals"]
238
239 if config["ldap_group_tree_dn"]:
240 envconfig["LDAP_GROUP_TREE_DN"] = config["ldap_group_tree_dn"]
241
sousaedu126a4432020-09-23 13:28:25 +0100242 if config["ldap_use_starttls"]:
243 envconfig["LDAP_USE_STARTTLS"] = config["ldap_use_starttls"]
244 envconfig["LDAP_TLS_CACERT_BASE64"] = config["ldap_tls_cacert_base64"]
245 envconfig["LDAP_TLS_REQ_CERT"] = config["ldap_tls_req_cert"]
246
247 return envconfig
248
sousaedu738bf6f2020-10-10 00:25:26 +0100249 def _make_pod_ingress_resources(self) -> List[Dict[str, Any]]:
250 """Generate pod ingress resources.
251
252 Returns:
253 List[Dict[str, Any]]: pod ingress resources.
254 """
David Garcia009a5d62020-08-27 16:53:44 +0200255 site_url = self.model.config["site_url"]
256
257 if not site_url:
258 return
259
260 parsed = urlparse(site_url)
261
262 if not parsed.scheme.startswith("http"):
263 return
264
265 max_file_size = self.model.config["max_file_size"]
266 ingress_whitelist_source_range = self.model.config[
267 "ingress_whitelist_source_range"
268 ]
269
270 annotations = {
271 "nginx.ingress.kubernetes.io/proxy-body-size": "{}m".format(max_file_size)
272 }
273
274 if ingress_whitelist_source_range:
275 annotations[
276 "nginx.ingress.kubernetes.io/whitelist-source-range"
277 ] = ingress_whitelist_source_range
278
279 ingress_spec_tls = None
280
281 if parsed.scheme == "https":
282 ingress_spec_tls = [{"hosts": [parsed.hostname]}]
283 tls_secret_name = self.model.config["tls_secret_name"]
284 if tls_secret_name:
285 ingress_spec_tls[0]["secretName"] = tls_secret_name
286 else:
287 annotations["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
288
289 ingress = {
290 "name": "{}-ingress".format(self.app.name),
291 "annotations": annotations,
292 "spec": {
293 "rules": [
294 {
295 "host": parsed.hostname,
296 "http": {
297 "paths": [
298 {
299 "path": "/",
300 "backend": {
301 "serviceName": self.app.name,
302 "servicePort": KEYSTONE_PORT,
303 },
304 }
305 ]
306 },
307 }
308 ],
309 },
310 }
311 if ingress_spec_tls:
312 ingress["spec"]["tls"] = ingress_spec_tls
313
314 return [ingress]
315
sousaedu738bf6f2020-10-10 00:25:26 +0100316 def _generate_keys(self) -> Tuple[List[str], List[str]]:
317 """Generating new fernet tokens.
David Garcia009a5d62020-08-27 16:53:44 +0200318
sousaedu738bf6f2020-10-10 00:25:26 +0100319 Returns:
320 Tuple[List[str], List[str]]: contains two lists of strings. First
321 list contains strings that represent
322 the keys for fernet and the second
323 list contains strins that represent
324 the keys for credentials.
325 """
326 fernet_keys = [
327 Fernet.generate_key().decode() for _ in range(NUMBER_FERNET_KEYS)
328 ]
329 credential_keys = [
330 Fernet.generate_key().decode() for _ in range(NUMBER_CREDENTIAL_KEYS)
331 ]
332
333 return (fernet_keys, credential_keys)
334
335 def _make_pod_files(
336 self, fernet_keys: List[str], credential_keys: List[str]
337 ) -> List[Dict[str, Any]]:
338 """Generating ConfigMap information.
339
340 Args:
341 fernet_keys (List[str]): keys for fernet.
342 credential_keys (List[str]): keys for credentials.
343
344 Returns:
345 List[Dict[str, Any]]: ConfigMap information.
346 """
347 files = [
348 {
349 "name": "fernet-keys",
350 "mountPath": FERNET_KEYS_PATH,
351 "files": [
352 {"path": str(key_id), "content": value}
353 for (key_id, value) in enumerate(fernet_keys)
354 ],
355 }
356 ]
357
358 files.append(
359 {
360 "name": "credential-keys",
361 "mountPath": CREDENTIAL_KEYS_PATH,
362 "files": [
363 {"path": str(key_id), "content": value}
364 for (key_id, value) in enumerate(credential_keys)
365 ],
366 }
367 )
368
369 return files
370
371 def configure_pod(self, event: EventBase) -> NoReturn:
372 """Assemble the pod spec and apply it, if possible.
373
374 Args:
375 event (EventBase): Hook or Relation event that started the
376 function.
377 """
David Garcia009a5d62020-08-27 16:53:44 +0200378 if not self.state.db_host:
379 self.unit.status = WaitingStatus("Waiting for database relation")
380 event.defer()
381 return
382
383 if not self.unit.is_leader():
sousaedu738bf6f2020-10-10 00:25:26 +0100384 self.unit.status = ActiveStatus("ready")
David Garcia009a5d62020-08-27 16:53:44 +0200385 return
386
sousaedu738bf6f2020-10-10 00:25:26 +0100387 if fernet_keys := self.state.fernet_keys:
388 fernet_keys = json.loads(fernet_keys)
389
390 if credential_keys := self.state.credential_keys:
391 credential_keys = json.loads(credential_keys)
392
393 now = datetime.now().timestamp()
394 keys_timestamp = self.state.keys_timestamp
395 token_expiration = self.model.config["token_expiration"]
396
397 valid_keys = (now - keys_timestamp) < token_expiration
398 if not credential_keys or not fernet_keys or not valid_keys:
399 fernet_keys, credential_keys = self._generate_keys()
400 self.state.fernet_keys = json.dumps(fernet_keys)
401 self.state.credential_keys = json.dumps(credential_keys)
402 self.state.keys_timestamp = now
403
David Garcia009a5d62020-08-27 16:53:44 +0200404 # Check problems in the settings
405 problems = self._check_settings()
406 if problems:
407 self.unit.status = BlockedStatus(problems)
408 return
409
410 self.unit.status = BlockedStatus("Assembling pod spec")
411 image_details = self._make_pod_image_details()
412 ports = self._make_pod_ports()
413 env_config = self._make_pod_envconfig()
414 ingress_resources = self._make_pod_ingress_resources()
sousaedu738bf6f2020-10-10 00:25:26 +0100415 files = self._make_pod_files(fernet_keys, credential_keys)
David Garcia009a5d62020-08-27 16:53:44 +0200416
417 pod_spec = {
418 "version": 3,
419 "containers": [
420 {
421 "name": self.framework.model.app.name,
422 "imageDetails": image_details,
423 "ports": ports,
424 "envConfig": env_config,
sousaedu738bf6f2020-10-10 00:25:26 +0100425 "volumeConfig": files,
David Garcia009a5d62020-08-27 16:53:44 +0200426 }
427 ],
428 "kubernetesResources": {"ingressResources": ingress_resources or []},
429 }
sousaedu738bf6f2020-10-10 00:25:26 +0100430
431 if self.state.pod_spec != (
432 pod_spec_json := json.dumps(pod_spec, sort_keys=True)
433 ):
434 self.state.pod_spec = pod_spec_json
435 self.model.pod.set_spec(pod_spec)
436
437 self.unit.status = ActiveStatus("ready")
David Garcia009a5d62020-08-27 16:53:44 +0200438
439
440if __name__ == "__main__":
441 main(KeystoneCharm)