Add Keystone charm
[osm/devops.git] / installers / charm / osm-keystone / src / charm.py
1 #!/usr/bin/env python3
2 # Copyright 2021 Canonical Ltd.
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License"); you may
5 # not use this file except in compliance with the License. You may obtain
6 # 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, WITHOUT
12 # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13 # License for the specific language governing permissions and limitations
14 # under the License.
15 #
16 # For those usages not covered by the Apache License, Version 2.0 please
17 # contact: legal@canonical.com
18 #
19 # To get in touch with the maintainers, please contact:
20 # osm-charmers@lists.launchpad.net
21 #
22 #
23 # This file populates the Actions tab on Charmhub.
24 # See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance.
25
26 """Keystone charm module."""
27
28 import logging
29 from datetime import datetime
30
31 from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch
32 from config_validator import ValidationError
33 from ops import pebble
34 from ops.charm import ActionEvent, CharmBase, ConfigChangedEvent, UpdateStatusEvent
35 from ops.main import main
36 from ops.model import ActiveStatus, BlockedStatus, Container, MaintenanceStatus
37
38 import cluster
39 from config import ConfigModel, MysqlConnectionData, get_environment, validate_config
40 from interfaces import KeystoneServer, MysqlClient
41
42 logger = logging.getLogger(__name__)
43
44
45 # We expect the keystone container to use the default port
46 PORT = 5000
47
48 KEY_SETUP_FILE = "/etc/keystone/key-setup"
49 CREDENTIAL_KEY_REPOSITORY = "/etc/keystone/credential-keys/"
50 FERNET_KEY_REPOSITORY = "/etc/keystone/fernet-keys/"
51 KEYSTONE_USER = "keystone"
52 KEYSTONE_GROUP = "keystone"
53 FERNET_MAX_ACTIVE_KEYS = 3
54 KEYSTONE_FOLDER = "/etc/keystone/"
55
56
57 class CharmError(Exception):
58 """Charm error exception."""
59
60
61 class KeystoneCharm(CharmBase):
62 """Keystone Charm operator."""
63
64 on = cluster.ClusterEvents()
65
66 def __init__(self, *args) -> None:
67 super().__init__(*args)
68 event_observe_mapping = {
69 self.on.keystone_pebble_ready: self._on_config_changed,
70 self.on.config_changed: self._on_config_changed,
71 self.on.update_status: self._on_update_status,
72 self.on.cluster_keys_changed: self._on_cluster_keys_changed,
73 self.on["keystone"].relation_joined: self._publish_keystone_info,
74 self.on["db"].relation_changed: self._on_config_changed,
75 self.on["db"].relation_broken: self._on_config_changed,
76 self.on["db-sync"].action: self._on_db_sync_action,
77 }
78 for event, observer in event_observe_mapping.items():
79 self.framework.observe(event, observer)
80 self.cluster = cluster.Cluster(self)
81 self.mysql_client = MysqlClient(self, relation_name="db")
82 self.keystone = KeystoneServer(self, relation_name="keystone")
83 self.service_patch = KubernetesServicePatch(self, [(f"{self.app.name}", PORT)])
84
85 @property
86 def container(self) -> Container:
87 """Property to get keystone container."""
88 return self.unit.get_container("keystone")
89
90 def _on_db_sync_action(self, event: ActionEvent):
91 process = self.container.exec(["keystone-manage", "db_sync"])
92 try:
93 process.wait()
94 event.set_results({"output": "db-sync was successfully executed."})
95 except pebble.ExecError as e:
96 error_message = f"db-sync action failed with code {e.exit_code} and stderr {e.stderr}."
97 logger.error(error_message)
98 event.fail(error_message)
99
100 def _publish_keystone_info(self, _):
101 """Handler for keystone-relation-joined."""
102 if self.unit.is_leader():
103 config = ConfigModel(**dict(self.config))
104 self.keystone.publish_info(
105 host=f"http://{self.app.name}:{PORT}/v3",
106 port=PORT,
107 user_domain_name=config.user_domain_name,
108 project_domain_name=config.project_domain_name,
109 username=config.service_username,
110 password=config.service_password,
111 service=config.service_project,
112 keystone_db_password=config.keystone_db_password,
113 region_id=config.region_id,
114 admin_username=config.admin_username,
115 admin_password=config.admin_password,
116 admin_project_name=config.admin_project,
117 )
118
119 def _on_config_changed(self, _: ConfigChangedEvent) -> None:
120 """Handler for config-changed event."""
121 if self.container.can_connect():
122 try:
123 self._handle_fernet_key_rotation()
124 self._safe_restart()
125 self.unit.status = ActiveStatus()
126 except CharmError as e:
127 self.unit.status = BlockedStatus(str(e))
128 except ValidationError as e:
129 self.unit.status = BlockedStatus(str(e))
130 else:
131 logger.info("pebble socket not available, deferring config-changed")
132 self.unit.status = MaintenanceStatus("waiting for pebble to start")
133
134 def _on_update_status(self, event: UpdateStatusEvent) -> None:
135 """Handler for update-status event."""
136 if self.container.can_connect():
137 self._handle_fernet_key_rotation()
138 else:
139 logger.info("pebble socket not available, deferring config-changed")
140 event.defer()
141 self.unit.status = MaintenanceStatus("waiting for pebble to start")
142
143 def _on_cluster_keys_changed(self, _) -> None:
144 """Handler for ClusterKeysChanged event."""
145 self._handle_fernet_key_rotation()
146
147 def _handle_fernet_key_rotation(self) -> None:
148 """Handles fernet key rotation.
149
150 First, the function writes the existing keys in the relation to disk.
151 Then, if the unit is the leader, checks if the keys should be rotated
152 or not.
153 """
154 self._key_write()
155 if self.unit.is_leader():
156 if not self.cluster.get_keys():
157 self._key_setup()
158 self._fernet_keys_rotate_and_sync()
159
160 def _key_write(self) -> None:
161 """Write keys to container from the relation data."""
162 if self.unit.is_leader():
163 return
164 keys = self.cluster.get_keys()
165 if not keys:
166 logger.debug('"key_repository" not in relation data yet...')
167 return
168
169 for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]:
170 self._create_keys_folders()
171 for key_number, key in keys[key_repository].items():
172 logger.debug(f"writing key {key_number} in {key_repository}")
173 file_path = f"{key_repository}{key_number}"
174 if self._file_changed(file_path, key):
175 self.container.push(
176 file_path,
177 key,
178 user=KEYSTONE_USER,
179 group=KEYSTONE_GROUP,
180 permissions=0o600,
181 )
182 self.container.push(KEY_SETUP_FILE, "")
183
184 def _file_changed(self, file_path: str, content: str) -> bool:
185 """Check if file in container has changed its value.
186
187 This function checks if the file exists in the container. If it does,
188 then it checks if the content of that file is equal to the content passed to
189 this function. If the content is equal, the function returns False, otherwise True.
190
191 Args:
192 file_path (str): File path in the container.
193 content (str): Content of the file.
194
195 Returns:
196 bool: True if the content of the file has changed, or the file doesn't exist in
197 the container. False if the content passed to this function is the same as
198 in the container.
199 """
200 if self._file_exists(file_path):
201 old_content = self.container.pull(file_path).read()
202 if old_content == content:
203 return False
204 return True
205
206 def _create_keys_folders(self) -> None:
207 """Create folders for Key repositories."""
208 fernet_key_repository_found = False
209 credential_key_repository_found = False
210 for file in self.container.list_files(KEYSTONE_FOLDER):
211 if file.type == pebble.FileType.DIRECTORY:
212 if file.path == CREDENTIAL_KEY_REPOSITORY:
213 credential_key_repository_found = True
214 if file.path == FERNET_KEY_REPOSITORY:
215 fernet_key_repository_found = True
216 if not fernet_key_repository_found:
217 self.container.make_dir(
218 FERNET_KEY_REPOSITORY,
219 user="keystone",
220 group="keystone",
221 permissions=0o700,
222 make_parents=True,
223 )
224 if not credential_key_repository_found:
225 self.container.make_dir(
226 CREDENTIAL_KEY_REPOSITORY,
227 user=KEYSTONE_USER,
228 group=KEYSTONE_GROUP,
229 permissions=0o700,
230 make_parents=True,
231 )
232
233 def _fernet_keys_rotate_and_sync(self) -> None:
234 """Rotate and sync the keys if the unit is the leader and the primary key has expired.
235
236 The modification time of the staging key (key with index '0') is used,
237 along with the config setting "token-expiration" to determine whether to
238 rotate the keys.
239
240 The rotation time = token-expiration / (max-active-keys - 2)
241 where max-active-keys has a minimum of 3.
242 """
243 if not self.unit.is_leader():
244 return
245 try:
246 fernet_key_file = self.container.list_files(f"{FERNET_KEY_REPOSITORY}0")[0]
247 last_rotation = fernet_key_file.last_modified.timestamp()
248 except pebble.APIError:
249 logger.warning(
250 "Fernet key rotation requested but key repository not " "initialized yet"
251 )
252 return
253
254 config = ConfigModel(**self.config)
255 rotation_time = config.token_expiration // (FERNET_MAX_ACTIVE_KEYS - 2)
256
257 now = datetime.now().timestamp()
258 if last_rotation + rotation_time > now:
259 # No rotation to do as not reached rotation time
260 logger.debug("No rotation needed")
261 self._key_leader_set()
262 return
263 # now rotate the keys and sync them
264 self._fernet_rotate()
265 self._key_leader_set()
266
267 logger.info("Rotated and started sync of fernet keys")
268
269 def _key_leader_set(self) -> None:
270 """Read current key sets and update peer relation data.
271
272 The keys are read from the `FERNET_KEY_REPOSITORY` and `CREDENTIAL_KEY_REPOSITORY`
273 directories. Note that this function will fail if it is called on the unit that is
274 not the leader.
275 """
276 disk_keys = {}
277 for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]:
278 disk_keys[key_repository] = {}
279 for file in self.container.list_files(key_repository):
280 key_content = self.container.pull(f"{key_repository}{file.name}").read()
281 disk_keys[key_repository][file.name] = key_content
282 self.cluster.save_keys(disk_keys)
283
284 def _fernet_rotate(self) -> None:
285 """Rotate Fernet keys.
286
287 To rotate the Fernet tokens, and create a new staging key, it calls (as the
288 "keystone" user):
289
290 keystone-manage fernet_rotate
291
292 Note that we do not rotate the Credential encryption keys.
293
294 Note that this does NOT synchronise the keys between the units. This is
295 performed in `self._key_leader_set`.
296 """
297 logger.debug("Rotating Fernet tokens")
298 try:
299 exec_command = [
300 "keystone-manage",
301 "fernet_rotate",
302 "--keystone-user",
303 KEYSTONE_USER,
304 "--keystone-group",
305 KEYSTONE_GROUP,
306 ]
307 logger.debug(f'Executing command: {" ".join(exec_command)}')
308 self.container.exec(exec_command).wait()
309 logger.info("Fernet keys successfully rotated.")
310 except pebble.ExecError as e:
311 logger.error(f"Fernet Key rotation failed: {e}")
312 logger.error("Exited with code %d. Stderr:", e.exit_code)
313 for line in e.stderr.splitlines():
314 logger.error(" %s", line)
315
316 def _key_setup(self) -> None:
317 """Initialize Fernet and Credential encryption key repositories.
318
319 To setup the key repositories:
320
321 keystone-manage fernet_setup
322 keystone-manage credential_setup
323
324 In addition we migrate any credentials currently stored in database using
325 the null key to be encrypted by the new credential key:
326
327 keystone-manage credential_migrate
328
329 Note that we only want to do this once, so we touch an empty file
330 (KEY_SETUP_FILE) to indicate that it has been done.
331 """
332 if self._file_exists(KEY_SETUP_FILE) or not self.unit.is_leader():
333 return
334
335 logger.debug("Setting up key repositories for Fernet tokens and Credential encryption.")
336 try:
337 for command in ["fernet_setup", "credential_setup"]:
338 exec_command = [
339 "keystone-manage",
340 command,
341 "--keystone-user",
342 KEYSTONE_USER,
343 "--keystone-group",
344 KEYSTONE_GROUP,
345 ]
346 logger.debug(f'Executing command: {" ".join(exec_command)}')
347 self.container.exec(exec_command).wait()
348 self.container.push(KEY_SETUP_FILE, "")
349 logger.info("Key repositories initialized successfully.")
350 except pebble.ExecError as e:
351 logger.error("Failed initializing key repositories.")
352 logger.error("Exited with code %d. Stderr:", e.exit_code)
353 for line in e.stderr.splitlines():
354 logger.error(" %s", line)
355
356 def _file_exists(self, path: str) -> bool:
357 """Check if a file exists in the container.
358
359 Args:
360 path (str): Path of the file to be checked.
361
362 Returns:
363 bool: True if the file exists, else False.
364 """
365 file_exists = None
366 try:
367 _ = self.container.pull(path)
368 file_exists = True
369 except pebble.PathError:
370 file_exists = False
371 exist_str = "exists" if file_exists else 'doesn"t exist'
372 logger.debug(f"File {path} {exist_str}.")
373 return file_exists
374
375 def _safe_restart(self) -> None:
376 """Safely restart the keystone service.
377
378 This function (re)starts the keystone service after doing some safety checks,
379 like validating the charm configuration, checking the mysql relation is ready.
380 """
381 validate_config(self.config)
382 self._check_mysql_data()
383 # Workaround: OS_AUTH_URL is not ready when the entrypoint restarts apache2.
384 # The function `self._patch_entrypoint` fixes that.
385 self._patch_entrypoint()
386 self._replan()
387
388 def _patch_entrypoint(self) -> None:
389 """Patches the entrypoint of the Keystone service.
390
391 The entrypoint that restarts apache2, expects immediate communication to OS_AUTH_URL.
392 This does not happen instantly. This function patches the entrypoint to wait until a
393 curl to OS_AUTH_URL succeeds.
394 """
395 installer_script = self.container.pull("/app/start.sh").read()
396 wait_until_ready_command = "until $(curl --output /dev/null --silent --head --fail $OS_AUTH_URL); do echo '...'; sleep 5; done"
397 self.container.push(
398 "/app/start-patched.sh",
399 installer_script.replace(
400 "source setup_env", f"source setup_env && {wait_until_ready_command}"
401 ),
402 permissions=0o755,
403 )
404
405 def _check_mysql_data(self) -> None:
406 """Check if the mysql relation is ready.
407
408 Raises:
409 CharmError: Error raised if the mysql relation is not ready.
410 """
411 if self.mysql_client.is_missing_data_in_unit() and not self.config.get("mysql-uri"):
412 raise CharmError("mysql relation is missing")
413
414 def _replan(self) -> None:
415 """Replan keystone service.
416
417 This function starts the keystone service if it is not running.
418 If the service started already, this function will restart the
419 service if there are any changes to the layer.
420 """
421 mysql_data = MysqlConnectionData(
422 self.config.get("mysql-uri")
423 or f"mysql://root:{self.mysql_client.root_password}@{self.mysql_client.host}:{self.mysql_client.port}/"
424 )
425 layer = {
426 "summary": "keystone layer",
427 "description": "pebble config layer for keystone",
428 "services": {
429 "keystone": {
430 "override": "replace",
431 "summary": "keystone service",
432 "command": "/app/start-patched.sh",
433 "startup": "enabled",
434 "environment": get_environment(self.app.name, self.config, mysql_data),
435 }
436 },
437 }
438 self.container.add_layer("keystone", layer, combine=True)
439 self.container.replan()
440
441
442 if __name__ == "__main__": # pragma: no cover
443 main(KeystoneCharm)