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
31 from ops
.framework
import StoredState
32 from ops
.main
import main
33 from ops
.model
import (
41 LOGGER
= logging
.getLogger(__name__
)
43 REQUIRED_SETTINGS
= []
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 KeystoneCharm(CharmBase
):
61 """Keystone K8s Charm"""
65 def __init__(self
, *args
) -> NoReturn
:
66 """Constructor of the Charm object.
67 Initializes internal state and register events it can handle.
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)
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
)
86 # Register relation events
87 self
.framework
.observe(
88 self
.on
.db_relation_changed
, self
._on
_db
_relation
_changed
90 self
.framework
.observe(
91 self
.on
.keystone_relation_joined
, self
._publish
_keystone
_info
94 def _publish_keystone_info(self
, event
: EventBase
) -> NoReturn
:
95 """Publishes keystone information for NBI usage through the keystone
99 event (EventBase): Keystone relation event to update NBI.
101 config
= self
.model
.config
102 if self
.unit
.is_leader():
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"],
117 for k
, v
in rel_data
.items():
118 event
.relation
.data
[self
.model
.unit
][k
] = v
120 def _on_db_relation_changed(self
, event
: EventBase
) -> NoReturn
:
121 """Reads information about the DB relation, in order for keystone to
125 event (EventBase): DB relation event to access database
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
)
135 def _check_settings(self
) -> str:
136 """Check if there any settings missing from Keystone configuration.
139 str: Information about the problems found (if any).
142 config
= self
.model
.config
144 for setting
in REQUIRED_SETTINGS
:
145 if not config
.get(setting
):
146 problem
= f
"missing config {setting}"
147 problems
.append(problem
)
149 return ";".join(problems
)
151 def _make_pod_image_details(self
) -> Dict
[str, str]:
152 """Generate the pod image details.
155 Dict[str, str]: pod image details.
157 config
= self
.model
.config
159 "imagePath": config
["image"],
161 if config
["image_username"]:
162 image_details
.update(
164 "username": config
["image_username"],
165 "password": config
["image_password"],
170 def _make_pod_ports(self
) -> List
[Dict
[str, Any
]]:
171 """Generate the pod ports details.
174 List[Dict[str, Any]]: pod ports details.
177 {"name": "keystone", "containerPort": KEYSTONE_PORT
, "protocol": "TCP"},
180 def _make_pod_envconfig(self
) -> Dict
[str, Any
]:
181 """Generate pod environment configuraiton.
184 Dict[str, Any]: pod environment configuration.
186 config
= self
.model
.config
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"],
204 if config
.get("ldap_enabled"):
205 envconfig
["LDAP_AUTHENTICATION_DOMAIN_NAME"] = config
[
206 "ldap_authentication_domain_name"
208 envconfig
["LDAP_URL"] = config
["ldap_url"]
209 envconfig
["LDAP_PAGE_SIZE"] = config
["ldap_page_size"]
210 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"]
217 envconfig
["LDAP_GROUP_OBJECTCLASS"] = config
["ldap_group_objectclass"]
219 if config
["ldap_bind_user"]:
220 envconfig
["LDAP_BIND_USER"] = config
["ldap_bind_user"]
222 if config
["ldap_bind_password"]:
223 envconfig
["LDAP_BIND_PASSWORD"] = config
["ldap_bind_password"]
225 if config
["ldap_user_tree_dn"]:
226 envconfig
["LDAP_USER_TREE_DN"] = config
["ldap_user_tree_dn"]
228 if config
["ldap_user_filter"]:
229 envconfig
["LDAP_USER_FILTER"] = config
["ldap_user_filter"]
231 if config
["ldap_user_enabled_attribute"]:
232 envconfig
["LDAP_USER_ENABLED_ATTRIBUTE"] = config
[
233 "ldap_user_enabled_attribute"
236 if config
["ldap_chase_referrals"]:
237 envconfig
["LDAP_CHASE_REFERRALS"] = config
["ldap_chase_referrals"]
239 if config
["ldap_group_tree_dn"]:
240 envconfig
["LDAP_GROUP_TREE_DN"] = config
["ldap_group_tree_dn"]
242 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"]
249 def _make_pod_ingress_resources(self
) -> List
[Dict
[str, Any
]]:
250 """Generate pod ingress resources.
253 List[Dict[str, Any]]: pod ingress resources.
255 site_url
= self
.model
.config
["site_url"]
260 parsed
= urlparse(site_url
)
262 if not parsed
.scheme
.startswith("http"):
265 max_file_size
= self
.model
.config
["max_file_size"]
266 ingress_whitelist_source_range
= self
.model
.config
[
267 "ingress_whitelist_source_range"
271 "nginx.ingress.kubernetes.io/proxy-body-size": "{}m".format(max_file_size
)
274 if ingress_whitelist_source_range
:
276 "nginx.ingress.kubernetes.io/whitelist-source-range"
277 ] = ingress_whitelist_source_range
279 ingress_spec_tls
= None
281 if parsed
.scheme
== "https":
282 ingress_spec_tls
= [{"hosts": [parsed
.hostname
]}]
283 tls_secret_name
= self
.model
.config
["tls_secret_name"]
285 ingress_spec_tls
[0]["secretName"] = tls_secret_name
287 annotations
["nginx.ingress.kubernetes.io/ssl-redirect"] = "false"
290 "name": "{}-ingress".format(self
.app
.name
),
291 "annotations": annotations
,
295 "host": parsed
.hostname
,
301 "serviceName": self
.app
.name
,
302 "servicePort": KEYSTONE_PORT
,
312 ingress
["spec"]["tls"] = ingress_spec_tls
316 def _generate_keys(self
) -> Tuple
[List
[str], List
[str]]:
317 """Generating new fernet tokens.
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.
327 Fernet
.generate_key().decode() for _
in range(NUMBER_FERNET_KEYS
)
330 Fernet
.generate_key().decode() for _
in range(NUMBER_CREDENTIAL_KEYS
)
333 return (fernet_keys
, credential_keys
)
336 self
, fernet_keys
: List
[str], credential_keys
: List
[str]
337 ) -> List
[Dict
[str, Any
]]:
338 """Generating ConfigMap information.
341 fernet_keys (List[str]): keys for fernet.
342 credential_keys (List[str]): keys for credentials.
345 List[Dict[str, Any]]: ConfigMap information.
349 "name": "fernet-keys",
350 "mountPath": FERNET_KEYS_PATH
,
352 {"path": str(key_id
), "content": value
}
353 for (key_id
, value
) in enumerate(fernet_keys
)
360 "name": "credential-keys",
361 "mountPath": CREDENTIAL_KEYS_PATH
,
363 {"path": str(key_id
), "content": value
}
364 for (key_id
, value
) in enumerate(credential_keys
)
371 def configure_pod(self
, event
: EventBase
) -> NoReturn
:
372 """Assemble the pod spec and apply it, if possible.
375 event (EventBase): Hook or Relation event that started the
378 if not self
.state
.db_host
:
379 self
.unit
.status
= WaitingStatus("Waiting for database relation")
383 if not self
.unit
.is_leader():
384 self
.unit
.status
= ActiveStatus("ready")
387 if fernet_keys
:= self
.state
.fernet_keys
:
388 fernet_keys
= json
.loads(fernet_keys
)
390 if credential_keys
:= self
.state
.credential_keys
:
391 credential_keys
= json
.loads(credential_keys
)
393 now
= datetime
.now().timestamp()
394 keys_timestamp
= self
.state
.keys_timestamp
395 token_expiration
= self
.model
.config
["token_expiration"]
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
404 # Check problems in the settings
405 problems
= self
._check
_settings
()
407 self
.unit
.status
= BlockedStatus(problems
)
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
()
415 files
= self
._make
_pod
_files
(fernet_keys
, credential_keys
)
421 "name": self
.framework
.model
.app
.name
,
422 "imageDetails": image_details
,
424 "envConfig": env_config
,
425 "volumeConfig": files
,
428 "kubernetesResources": {"ingressResources": ingress_resources
or []},
431 if self
.state
.pod_spec
!= (
432 pod_spec_json
:= json
.dumps(pod_spec
, sort_keys
=True)
434 self
.state
.pod_spec
= pod_spec_json
435 self
.model
.pod
.set_spec(pod_spec
)
437 self
.unit
.status
= ActiveStatus("ready")
440 if __name__
== "__main__":