Add Keystone charm
[osm/devops.git] / installers / charm / osm-keystone / src / charm.py
diff --git a/installers/charm/osm-keystone/src/charm.py b/installers/charm/osm-keystone/src/charm.py
new file mode 100755 (executable)
index 0000000..c368ade
--- /dev/null
@@ -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)