| Patricia Reinoso | d5b463c | 2023-05-31 08:37:18 +0000 | [diff] [blame] | 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) |