2 # Copyright 2020 Canonical Ltd.
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
8 # http://www.apache.org/licenses/LICENSE-2.0
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.
18 from datetime
import datetime
26 from urllib
.parse
import urlparse
28 from cryptography
.fernet
import Fernet
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 (
41 LOGGER
= logging
.getLogger(__name__
)
43 REQUIRED_SETTINGS
= ["token_expiration"]
45 # This is hardcoded in the keystone container script
46 DATABASE_NAME
= "keystone"
48 # We expect the keystone container to use the default port
51 # Number of keys need might need to be adjusted in the future
52 NUMBER_FERNET_KEYS
= 2
53 NUMBER_CREDENTIAL_KEYS
= 2
56 CREDENTIAL_KEYS_PATH
= "/etc/keystone/credential-keys"
57 FERNET_KEYS_PATH
= "/etc/keystone/fernet-keys"
60 class ConfigurePodEvent(EventBase
):
61 """Configure Pod event"""
66 class KeystoneEvents(CharmEvents
):
69 configure_pod
= EventSource(ConfigurePodEvent
)
72 class KeystoneCharm(CharmBase
):
73 """Keystone K8s Charm"""
78 def __init__(self
, *args
) -> NoReturn
:
79 """Constructor of the Charm object.
80 Initializes internal state and register events it can handle.
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)
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
)
99 # Registering custom internal events
100 self
.framework
.observe(self
.on
.configure_pod
, self
.configure_pod
)
102 # Register relation events
103 self
.framework
.observe(
104 self
.on
.db_relation_changed
, self
._on
_db
_relation
_changed
106 self
.framework
.observe(
107 self
.on
.db_relation_departed
, self
._on
_db
_relation
_departed
109 self
.framework
.observe(
110 self
.on
.keystone_relation_joined
, self
._publish
_keystone
_info
113 def _publish_keystone_info(self
, event
: EventBase
) -> NoReturn
:
114 """Publishes keystone information for NBI usage through the keystone
118 event (EventBase): Keystone relation event to update NBI.
120 config
= self
.model
.config
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"],
135 for k
, v
in rel_data
.items():
136 event
.relation
.data
[self
.model
.unit
][k
] = v
138 def _on_db_relation_changed(self
, event
: EventBase
) -> NoReturn
:
139 """Reads information about the DB relation, in order for keystone to
143 event (EventBase): DB relation event to access database
146 if event
.unit
not in event
.relation
.data
:
148 relation_data
= event
.relation
.data
[event
.unit
]
149 db_host
= relation_data
.get("host")
150 db_port
= int(relation_data
.get("port", 3306))
152 db_password
= relation_data
.get("root_password")
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
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()
172 def _on_db_relation_departed(self
, event
: EventBase
) -> NoReturn
:
173 """Clears data from db relation.
176 event (EventBase): DB relation event.
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()
185 def _check_settings(self
) -> str:
186 """Check if there any settings missing from Keystone configuration.
189 str: Information about the problems found (if any).
192 config
= self
.model
.config
194 for setting
in REQUIRED_SETTINGS
:
195 if not config
.get(setting
):
196 problem
= f
"missing config {setting}"
197 problems
.append(problem
)
199 return ";".join(problems
)
201 def _make_pod_image_details(self
) -> Dict
[str, str]:
202 """Generate the pod image details.
205 Dict[str, str]: pod image details.
207 config
= self
.model
.config
209 "imagePath": config
["image"],
211 if config
["image_username"]:
212 image_details
.update(
214 "username": config
["image_username"],
215 "password": config
["image_password"],
220 def _make_pod_ports(self
) -> List
[Dict
[str, Any
]]:
221 """Generate the pod ports details.
224 List[Dict[str, Any]]: pod ports details.
227 {"name": "keystone", "containerPort": KEYSTONE_PORT
, "protocol": "TCP"},
230 def _make_pod_envconfig(self
) -> Dict
[str, Any
]:
231 """Generate pod environment configuraiton.
234 Dict[str, Any]: pod environment configuration.
236 config
= self
.model
.config
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"],
254 if config
.get("ldap_enabled"):
255 envconfig
["LDAP_AUTHENTICATION_DOMAIN_NAME"] = config
[
256 "ldap_authentication_domain_name"
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"]
269 if config
["ldap_bind_user"]:
270 envconfig
["LDAP_BIND_USER"] = config
["ldap_bind_user"]
272 if config
["ldap_bind_password"]:
273 envconfig
["LDAP_BIND_PASSWORD"] = config
["ldap_bind_password"]
275 if config
["ldap_user_tree_dn"]:
276 envconfig
["LDAP_USER_TREE_DN"] = config
["ldap_user_tree_dn"]
278 if config
["ldap_user_filter"]:
279 envconfig
["LDAP_USER_FILTER"] = config
["ldap_user_filter"]
281 if config
["ldap_user_enabled_attribute"]:
282 envconfig
["LDAP_USER_ENABLED_ATTRIBUTE"] = config
[
283 "ldap_user_enabled_attribute"
286 if config
["ldap_chase_referrals"]:
287 envconfig
["LDAP_CHASE_REFERRALS"] = config
["ldap_chase_referrals"]
289 if config
["ldap_group_tree_dn"]:
290 envconfig
["LDAP_GROUP_TREE_DN"] = config
["ldap_group_tree_dn"]
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"]
299 def _make_pod_ingress_resources(self
) -> List
[Dict
[str, Any
]]:
300 """Generate pod ingress resources.
303 List[Dict[str, Any]]: pod ingress resources.
305 site_url
= self
.model
.config
["site_url"]
310 parsed
= urlparse(site_url
)
312 if not parsed
.scheme
.startswith("http"):
315 max_file_size
= self
.model
.config
["max_file_size"]
316 ingress_whitelist_source_range
= self
.model
.config
[
317 "ingress_whitelist_source_range"
321 "nginx.ingress.kubernetes.io/proxy-body-size": "{}m".format(max_file_size
)
324 if ingress_whitelist_source_range
:
326 "nginx.ingress.kubernetes.io/whitelist-source-range"
327 ] = ingress_whitelist_source_range
329 ingress_spec_tls
= None
331 if parsed
.scheme
== "https":
332 ingress_spec_tls
= [{"hosts": [parsed
.hostname
]}]
333 tls_secret_name
= self
.model
.config
["tls_secret_name"]
335 ingress_spec_tls
[0]["secretName"] = tls_secret_name
337 annotations
["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
340 "name": "{}-ingress".format(self
.app
.name
),
341 "annotations": annotations
,
345 "host": parsed
.hostname
,
351 "serviceName": self
.app
.name
,
352 "servicePort": KEYSTONE_PORT
,
362 ingress
["spec"]["tls"] = ingress_spec_tls
366 def _generate_keys(self
) -> Tuple
[List
[str], List
[str]]:
367 """Generating new fernet tokens.
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.
377 Fernet
.generate_key().decode() for _
in range(NUMBER_FERNET_KEYS
)
380 Fernet
.generate_key().decode() for _
in range(NUMBER_CREDENTIAL_KEYS
)
383 return (fernet_keys
, credential_keys
)
386 self
, fernet_keys
: List
[str], credential_keys
: List
[str]
387 ) -> List
[Dict
[str, Any
]]:
388 """Generating ConfigMap information.
391 fernet_keys (List[str]): keys for fernet.
392 credential_keys (List[str]): keys for credentials.
395 List[Dict[str, Any]]: ConfigMap information.
399 "name": "fernet-keys",
400 "mountPath": FERNET_KEYS_PATH
,
402 {"path": str(key_id
), "content": value
}
403 for (key_id
, value
) in enumerate(fernet_keys
)
410 "name": "credential-keys",
411 "mountPath": CREDENTIAL_KEYS_PATH
,
413 {"path": str(key_id
), "content": value
}
414 for (key_id
, value
) in enumerate(credential_keys
)
421 def configure_pod(self
, event
: EventBase
) -> NoReturn
:
422 """Assemble the pod spec and apply it, if possible.
425 event (EventBase): Hook or Relation event that started the
428 if not self
.state
.db_host
:
429 self
.unit
.status
= WaitingStatus("Waiting for database relation")
433 if not self
.unit
.is_leader():
434 self
.unit
.status
= ActiveStatus("ready")
437 if fernet_keys
:= self
.state
.fernet_keys
:
438 fernet_keys
= json
.loads(fernet_keys
)
440 if credential_keys
:= self
.state
.credential_keys
:
441 credential_keys
= json
.loads(credential_keys
)
443 now
= datetime
.now().timestamp()
444 keys_timestamp
= self
.state
.keys_timestamp
445 token_expiration
= self
.model
.config
["token_expiration"]
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
454 # Check problems in the settings
455 problems
= self
._check
_settings
()
457 self
.unit
.status
= BlockedStatus(problems
)
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
)
471 "name": self
.framework
.model
.app
.name
,
472 "imageDetails": image_details
,
474 "envConfig": env_config
,
475 "volumeConfig": files
,
478 "kubernetesResources": {"ingressResources": ingress_resources
or []},
481 if self
.state
.pod_spec
!= (
482 pod_spec_json
:= json
.dumps(pod_spec
, sort_keys
=True)
484 self
.state
.pod_spec
= pod_spec_json
485 self
.model
.pod
.set_spec(pod_spec
)
487 self
.unit
.status
= ActiveStatus("ready")
490 if __name__
== "__main__":