X-Git-Url: https://osm.etsi.org/gitweb/?a=blobdiff_plain;f=installers%2Fcharm%2Fosm-keystone%2Fsrc%2Fcharm.py;fp=installers%2Fcharm%2Fosm-keystone%2Fsrc%2Fcharm.py;h=c368ade3453511387664655c55cff19f64f7bbc7;hb=d5b463cc04638b134d982c386596c82258c509fb;hp=0000000000000000000000000000000000000000;hpb=6003632d2c82a93d71a8826c6bcd182e22140bc3;p=osm%2Fdevops.git diff --git a/installers/charm/osm-keystone/src/charm.py b/installers/charm/osm-keystone/src/charm.py new file mode 100755 index 00000000..c368ade3 --- /dev/null +++ b/installers/charm/osm-keystone/src/charm.py @@ -0,0 +1,443 @@ +#!/usr/bin/env python3 +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +# +# +# This file populates the Actions tab on Charmhub. +# See https://juju.is/docs/some-url-to-be-determined/ for a checklist and guidance. + +"""Keystone charm module.""" + +import logging +from datetime import datetime + +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch +from config_validator import ValidationError +from ops import pebble +from ops.charm import ActionEvent, CharmBase, ConfigChangedEvent, UpdateStatusEvent +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus, Container, MaintenanceStatus + +import cluster +from config import ConfigModel, MysqlConnectionData, get_environment, validate_config +from interfaces import KeystoneServer, MysqlClient + +logger = logging.getLogger(__name__) + + +# We expect the keystone container to use the default port +PORT = 5000 + +KEY_SETUP_FILE = "/etc/keystone/key-setup" +CREDENTIAL_KEY_REPOSITORY = "/etc/keystone/credential-keys/" +FERNET_KEY_REPOSITORY = "/etc/keystone/fernet-keys/" +KEYSTONE_USER = "keystone" +KEYSTONE_GROUP = "keystone" +FERNET_MAX_ACTIVE_KEYS = 3 +KEYSTONE_FOLDER = "/etc/keystone/" + + +class CharmError(Exception): + """Charm error exception.""" + + +class KeystoneCharm(CharmBase): + """Keystone Charm operator.""" + + on = cluster.ClusterEvents() + + def __init__(self, *args) -> None: + super().__init__(*args) + event_observe_mapping = { + self.on.keystone_pebble_ready: self._on_config_changed, + self.on.config_changed: self._on_config_changed, + self.on.update_status: self._on_update_status, + self.on.cluster_keys_changed: self._on_cluster_keys_changed, + self.on["keystone"].relation_joined: self._publish_keystone_info, + self.on["db"].relation_changed: self._on_config_changed, + self.on["db"].relation_broken: self._on_config_changed, + self.on["db-sync"].action: self._on_db_sync_action, + } + for event, observer in event_observe_mapping.items(): + self.framework.observe(event, observer) + self.cluster = cluster.Cluster(self) + self.mysql_client = MysqlClient(self, relation_name="db") + self.keystone = KeystoneServer(self, relation_name="keystone") + self.service_patch = KubernetesServicePatch(self, [(f"{self.app.name}", PORT)]) + + @property + def container(self) -> Container: + """Property to get keystone container.""" + return self.unit.get_container("keystone") + + def _on_db_sync_action(self, event: ActionEvent): + process = self.container.exec(["keystone-manage", "db_sync"]) + try: + process.wait() + event.set_results({"output": "db-sync was successfully executed."}) + except pebble.ExecError as e: + error_message = f"db-sync action failed with code {e.exit_code} and stderr {e.stderr}." + logger.error(error_message) + event.fail(error_message) + + def _publish_keystone_info(self, _): + """Handler for keystone-relation-joined.""" + if self.unit.is_leader(): + config = ConfigModel(**dict(self.config)) + self.keystone.publish_info( + host=f"http://{self.app.name}:{PORT}/v3", + port=PORT, + user_domain_name=config.user_domain_name, + project_domain_name=config.project_domain_name, + username=config.service_username, + password=config.service_password, + service=config.service_project, + keystone_db_password=config.keystone_db_password, + region_id=config.region_id, + admin_username=config.admin_username, + admin_password=config.admin_password, + admin_project_name=config.admin_project, + ) + + def _on_config_changed(self, _: ConfigChangedEvent) -> None: + """Handler for config-changed event.""" + if self.container.can_connect(): + try: + self._handle_fernet_key_rotation() + self._safe_restart() + self.unit.status = ActiveStatus() + except CharmError as e: + self.unit.status = BlockedStatus(str(e)) + except ValidationError as e: + self.unit.status = BlockedStatus(str(e)) + else: + logger.info("pebble socket not available, deferring config-changed") + self.unit.status = MaintenanceStatus("waiting for pebble to start") + + def _on_update_status(self, event: UpdateStatusEvent) -> None: + """Handler for update-status event.""" + if self.container.can_connect(): + self._handle_fernet_key_rotation() + else: + logger.info("pebble socket not available, deferring config-changed") + event.defer() + self.unit.status = MaintenanceStatus("waiting for pebble to start") + + def _on_cluster_keys_changed(self, _) -> None: + """Handler for ClusterKeysChanged event.""" + self._handle_fernet_key_rotation() + + def _handle_fernet_key_rotation(self) -> None: + """Handles fernet key rotation. + + First, the function writes the existing keys in the relation to disk. + Then, if the unit is the leader, checks if the keys should be rotated + or not. + """ + self._key_write() + if self.unit.is_leader(): + if not self.cluster.get_keys(): + self._key_setup() + self._fernet_keys_rotate_and_sync() + + def _key_write(self) -> None: + """Write keys to container from the relation data.""" + if self.unit.is_leader(): + return + keys = self.cluster.get_keys() + if not keys: + logger.debug('"key_repository" not in relation data yet...') + return + + for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]: + self._create_keys_folders() + for key_number, key in keys[key_repository].items(): + logger.debug(f"writing key {key_number} in {key_repository}") + file_path = f"{key_repository}{key_number}" + if self._file_changed(file_path, key): + self.container.push( + file_path, + key, + user=KEYSTONE_USER, + group=KEYSTONE_GROUP, + permissions=0o600, + ) + self.container.push(KEY_SETUP_FILE, "") + + def _file_changed(self, file_path: str, content: str) -> bool: + """Check if file in container has changed its value. + + This function checks if the file exists in the container. If it does, + then it checks if the content of that file is equal to the content passed to + this function. If the content is equal, the function returns False, otherwise True. + + Args: + file_path (str): File path in the container. + content (str): Content of the file. + + Returns: + bool: True if the content of the file has changed, or the file doesn't exist in + the container. False if the content passed to this function is the same as + in the container. + """ + if self._file_exists(file_path): + old_content = self.container.pull(file_path).read() + if old_content == content: + return False + return True + + def _create_keys_folders(self) -> None: + """Create folders for Key repositories.""" + fernet_key_repository_found = False + credential_key_repository_found = False + for file in self.container.list_files(KEYSTONE_FOLDER): + if file.type == pebble.FileType.DIRECTORY: + if file.path == CREDENTIAL_KEY_REPOSITORY: + credential_key_repository_found = True + if file.path == FERNET_KEY_REPOSITORY: + fernet_key_repository_found = True + if not fernet_key_repository_found: + self.container.make_dir( + FERNET_KEY_REPOSITORY, + user="keystone", + group="keystone", + permissions=0o700, + make_parents=True, + ) + if not credential_key_repository_found: + self.container.make_dir( + CREDENTIAL_KEY_REPOSITORY, + user=KEYSTONE_USER, + group=KEYSTONE_GROUP, + permissions=0o700, + make_parents=True, + ) + + def _fernet_keys_rotate_and_sync(self) -> None: + """Rotate and sync the keys if the unit is the leader and the primary key has expired. + + The modification time of the staging key (key with index '0') is used, + along with the config setting "token-expiration" to determine whether to + rotate the keys. + + The rotation time = token-expiration / (max-active-keys - 2) + where max-active-keys has a minimum of 3. + """ + if not self.unit.is_leader(): + return + try: + fernet_key_file = self.container.list_files(f"{FERNET_KEY_REPOSITORY}0")[0] + last_rotation = fernet_key_file.last_modified.timestamp() + except pebble.APIError: + logger.warning( + "Fernet key rotation requested but key repository not " "initialized yet" + ) + return + + config = ConfigModel(**self.config) + rotation_time = config.token_expiration // (FERNET_MAX_ACTIVE_KEYS - 2) + + now = datetime.now().timestamp() + if last_rotation + rotation_time > now: + # No rotation to do as not reached rotation time + logger.debug("No rotation needed") + self._key_leader_set() + return + # now rotate the keys and sync them + self._fernet_rotate() + self._key_leader_set() + + logger.info("Rotated and started sync of fernet keys") + + def _key_leader_set(self) -> None: + """Read current key sets and update peer relation data. + + The keys are read from the `FERNET_KEY_REPOSITORY` and `CREDENTIAL_KEY_REPOSITORY` + directories. Note that this function will fail if it is called on the unit that is + not the leader. + """ + disk_keys = {} + for key_repository in [FERNET_KEY_REPOSITORY, CREDENTIAL_KEY_REPOSITORY]: + disk_keys[key_repository] = {} + for file in self.container.list_files(key_repository): + key_content = self.container.pull(f"{key_repository}{file.name}").read() + disk_keys[key_repository][file.name] = key_content + self.cluster.save_keys(disk_keys) + + def _fernet_rotate(self) -> None: + """Rotate Fernet keys. + + To rotate the Fernet tokens, and create a new staging key, it calls (as the + "keystone" user): + + keystone-manage fernet_rotate + + Note that we do not rotate the Credential encryption keys. + + Note that this does NOT synchronise the keys between the units. This is + performed in `self._key_leader_set`. + """ + logger.debug("Rotating Fernet tokens") + try: + exec_command = [ + "keystone-manage", + "fernet_rotate", + "--keystone-user", + KEYSTONE_USER, + "--keystone-group", + KEYSTONE_GROUP, + ] + logger.debug(f'Executing command: {" ".join(exec_command)}') + self.container.exec(exec_command).wait() + logger.info("Fernet keys successfully rotated.") + except pebble.ExecError as e: + logger.error(f"Fernet Key rotation failed: {e}") + logger.error("Exited with code %d. Stderr:", e.exit_code) + for line in e.stderr.splitlines(): + logger.error(" %s", line) + + def _key_setup(self) -> None: + """Initialize Fernet and Credential encryption key repositories. + + To setup the key repositories: + + keystone-manage fernet_setup + keystone-manage credential_setup + + In addition we migrate any credentials currently stored in database using + the null key to be encrypted by the new credential key: + + keystone-manage credential_migrate + + Note that we only want to do this once, so we touch an empty file + (KEY_SETUP_FILE) to indicate that it has been done. + """ + if self._file_exists(KEY_SETUP_FILE) or not self.unit.is_leader(): + return + + logger.debug("Setting up key repositories for Fernet tokens and Credential encryption.") + try: + for command in ["fernet_setup", "credential_setup"]: + exec_command = [ + "keystone-manage", + command, + "--keystone-user", + KEYSTONE_USER, + "--keystone-group", + KEYSTONE_GROUP, + ] + logger.debug(f'Executing command: {" ".join(exec_command)}') + self.container.exec(exec_command).wait() + self.container.push(KEY_SETUP_FILE, "") + logger.info("Key repositories initialized successfully.") + except pebble.ExecError as e: + logger.error("Failed initializing key repositories.") + logger.error("Exited with code %d. Stderr:", e.exit_code) + for line in e.stderr.splitlines(): + logger.error(" %s", line) + + def _file_exists(self, path: str) -> bool: + """Check if a file exists in the container. + + Args: + path (str): Path of the file to be checked. + + Returns: + bool: True if the file exists, else False. + """ + file_exists = None + try: + _ = self.container.pull(path) + file_exists = True + except pebble.PathError: + file_exists = False + exist_str = "exists" if file_exists else 'doesn"t exist' + logger.debug(f"File {path} {exist_str}.") + return file_exists + + def _safe_restart(self) -> None: + """Safely restart the keystone service. + + This function (re)starts the keystone service after doing some safety checks, + like validating the charm configuration, checking the mysql relation is ready. + """ + validate_config(self.config) + self._check_mysql_data() + # Workaround: OS_AUTH_URL is not ready when the entrypoint restarts apache2. + # The function `self._patch_entrypoint` fixes that. + self._patch_entrypoint() + self._replan() + + def _patch_entrypoint(self) -> None: + """Patches the entrypoint of the Keystone service. + + The entrypoint that restarts apache2, expects immediate communication to OS_AUTH_URL. + This does not happen instantly. This function patches the entrypoint to wait until a + curl to OS_AUTH_URL succeeds. + """ + installer_script = self.container.pull("/app/start.sh").read() + wait_until_ready_command = "until $(curl --output /dev/null --silent --head --fail $OS_AUTH_URL); do echo '...'; sleep 5; done" + self.container.push( + "/app/start-patched.sh", + installer_script.replace( + "source setup_env", f"source setup_env && {wait_until_ready_command}" + ), + permissions=0o755, + ) + + def _check_mysql_data(self) -> None: + """Check if the mysql relation is ready. + + Raises: + CharmError: Error raised if the mysql relation is not ready. + """ + if self.mysql_client.is_missing_data_in_unit() and not self.config.get("mysql-uri"): + raise CharmError("mysql relation is missing") + + def _replan(self) -> None: + """Replan keystone service. + + This function starts the keystone service if it is not running. + If the service started already, this function will restart the + service if there are any changes to the layer. + """ + mysql_data = MysqlConnectionData( + self.config.get("mysql-uri") + or f"mysql://root:{self.mysql_client.root_password}@{self.mysql_client.host}:{self.mysql_client.port}/" + ) + layer = { + "summary": "keystone layer", + "description": "pebble config layer for keystone", + "services": { + "keystone": { + "override": "replace", + "summary": "keystone service", + "command": "/app/start-patched.sh", + "startup": "enabled", + "environment": get_environment(self.app.name, self.config, mysql_data), + } + }, + } + self.container.add_layer("keystone", layer, combine=True) + self.container.replan() + + +if __name__ == "__main__": # pragma: no cover + main(KeystoneCharm)