+#!/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)