Revert "Feature 11071: Modular OSM installation. Remove charms, juju and lxd"
This reverts commit a0f0d8ef4f2aa0dd227ecb651002490b66498bab.
Change-Id: I92394e4074dad4e457c107c58e4ebc17d507f8b2
Signed-off-by: garciadeblas <gerardo.garciadeblas@telefonica.com>
diff --git a/installers/charm/osm-keystone/src/charm.py b/installers/charm/osm-keystone/src/charm.py
new file mode 100755
index 0000000..c368ade
--- /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)
diff --git a/installers/charm/osm-keystone/src/cluster.py b/installers/charm/osm-keystone/src/cluster.py
new file mode 100644
index 0000000..f38adec
--- /dev/null
+++ b/installers/charm/osm-keystone/src/cluster.py
@@ -0,0 +1,135 @@
+# 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 cluster library.
+
+This library allows the integration with Apache Guacd charm. Is is published as part of the
+[davigar15-apache-guacd]((https://charmhub.io/davigar15-apache-guacd) charm.
+
+The charm that requires guacd should include the following content in its metadata.yaml:
+
+```yaml
+# ...
+peers:
+ cluster:
+ interface: cluster
+# ...
+```
+
+A typical example of including this library might be:
+
+```python
+# ...
+from ops.framework import StoredState
+from charms.keystone.v0 import cluster
+
+class SomeApplication(CharmBase):
+ on = cluster.ClusterEvents()
+
+ def __init__(self, *args):
+ # ...
+ self.cluster = cluster.Cluster(self)
+ self.framework.observe(self.on.cluster_keys_changed, self._cluster_keys_changed)
+ # ...
+
+ def _cluster_keys_changed(self, _):
+ fernet_keys = self.cluster.fernet_keys
+ credential_keys = self.cluster.credential_keys
+ # ...
+```
+"""
+
+
+import json
+import logging
+from typing import Any, Dict, List
+
+from ops.charm import CharmEvents
+from ops.framework import EventBase, EventSource, Object
+from ops.model import Relation
+
+# Number of keys need might need to be adjusted in the future
+NUMBER_FERNET_KEYS = 2
+NUMBER_CREDENTIAL_KEYS = 2
+
+logger = logging.getLogger(__name__)
+
+
+class ClusterKeysChangedEvent(EventBase):
+ """Event to announce a change in the Guacd service."""
+
+
+class ClusterEvents(CharmEvents):
+ """Cluster Events."""
+
+ cluster_keys_changed = EventSource(ClusterKeysChangedEvent)
+
+
+class Cluster(Object):
+ """Peer relation."""
+
+ def __init__(self, charm):
+ super().__init__(charm, "cluster")
+ self.charm = charm
+
+ @property
+ def fernet_keys(self) -> List[str]:
+ """Fernet keys."""
+ relation: Relation = self.model.get_relation("cluster")
+ application_data = relation.data[self.model.app]
+ return json.loads(application_data.get("keys-fernet", "[]"))
+
+ @property
+ def credential_keys(self) -> List[str]:
+ """Credential keys."""
+ relation: Relation = self.model.get_relation("cluster")
+ application_data = relation.data[self.model.app]
+ return json.loads(application_data.get("keys-credential", "[]"))
+
+ def save_keys(self, keys: Dict[str, Any]) -> None:
+ """Generate fernet and credential keys.
+
+ This method will generate new keys and fire the cluster_keys_changed event.
+ """
+ logger.debug("Saving keys...")
+ relation: Relation = self.model.get_relation("cluster")
+ data = relation.data[self.model.app]
+ current_keys_str = data.get("key_repository", "{}")
+ current_keys = json.loads(current_keys_str)
+ if current_keys != keys:
+ data["key_repository"] = json.dumps(keys)
+ self.charm.on.cluster_keys_changed.emit()
+ logger.info("Keys saved!")
+
+ def get_keys(self) -> Dict[str, Any]:
+ """Get keys from the relation.
+
+ Returns:
+ Dict[str, Any]: Dictionary with the keys.
+ """
+ relation: Relation = self.model.get_relation("cluster")
+ data = relation.data[self.model.app]
+ current_keys_str = data.get("key_repository", "{}")
+ current_keys = json.loads(current_keys_str)
+ return current_keys
diff --git a/installers/charm/osm-keystone/src/config.py b/installers/charm/osm-keystone/src/config.py
new file mode 100644
index 0000000..803d564
--- /dev/null
+++ b/installers/charm/osm-keystone/src/config.py
@@ -0,0 +1,184 @@
+#!/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.
+
+"""Module that takes take of the charm configuration."""
+
+import re
+from typing import Any, Dict, Optional
+
+from config_validator import ConfigValidator, ValidationError
+from ops.model import ConfigData
+
+
+class MysqlConnectionData:
+ """Mysql Connection Data class."""
+
+ _compiled_regex = re.compile(
+ r"^mysql\:\/\/{}@{}\/{}?$".format(
+ r"(?P<username>[_\w]+):(?P<password>[\w\W]+)",
+ r"(?P<host>[\-\.\w]+):(?P<port>\d+)",
+ r"(?P<database>[_\w]+)",
+ )
+ )
+
+ def __init__(self, mysql_uri: str):
+ match = self._compiled_regex.search(mysql_uri)
+ if not match:
+ raise ValidationError("mysql_uri is not properly formed")
+ mysql_data = match.groupdict()
+ self.host = mysql_data.get("host")
+ self.port = int(mysql_data.get("port"))
+ self.username = mysql_data.get("username")
+ self.password = mysql_data.get("password")
+ self.database = mysql_data.get("database")
+ self.uri = mysql_uri
+
+
+def validate_config(config: ConfigData):
+ """Validate charm configuration.
+
+ Args:
+ config (ConfigData): Charm configuration.
+
+ Raises:
+ config_validator.ValidationError if the validation failed.
+ """
+ kwargs: Dict[str, Any] = config
+ ConfigModel(**kwargs)
+ ConfigLdapModel(**kwargs)
+
+
+def get_environment(
+ service_name: str, config: ConfigData, mysql_data: MysqlConnectionData
+) -> Dict[str, Any]:
+ """Get environment variables.
+
+ Args:
+ service_name (str): Cluster IP service name.
+ config (ConfigData): Charm configuration.
+
+ Returns:
+ Dict[str, Any]: Dictionary with the environment variables for Keystone service.
+ """
+ kwargs: Dict[str, Any] = config
+ config = ConfigModel(**kwargs)
+ config_ldap = ConfigLdapModel(**kwargs)
+ environment = {
+ "DB_HOST": mysql_data.host,
+ "DB_PORT": mysql_data.port,
+ "ROOT_DB_USER": mysql_data.username,
+ "ROOT_DB_PASSWORD": mysql_data.password,
+ "REGION_ID": config.region_id,
+ "KEYSTONE_HOST": service_name,
+ "KEYSTONE_DB_PASSWORD": config.keystone_db_password,
+ "ADMIN_USERNAME": config.admin_username,
+ "ADMIN_PASSWORD": config.admin_password,
+ "ADMIN_PROJECT": config.admin_project,
+ "SERVICE_USERNAME": config.service_username,
+ "SERVICE_PASSWORD": config.service_password,
+ "SERVICE_PROJECT": config.service_project,
+ }
+ if config_ldap.ldap_enabled:
+ environment.update(
+ {
+ "LDAP_AUTHENTICATION_DOMAIN_NAME": config_ldap.ldap_authentication_domain_name,
+ "LDAP_URL": config_ldap.ldap_url,
+ "LDAP_PAGE_SIZE": str(config_ldap.ldap_page_size),
+ "LDAP_USER_OBJECTCLASS": config_ldap.ldap_user_objectclass,
+ "LDAP_USER_ID_ATTRIBUTE": config_ldap.ldap_user_id_attribute,
+ "LDAP_USER_NAME_ATTRIBUTE": config_ldap.ldap_user_name_attribute,
+ "LDAP_USER_PASS_ATTRIBUTE": config_ldap.ldap_user_pass_attribute,
+ "LDAP_USER_ENABLED_MASK": str(config_ldap.ldap_user_enabled_mask),
+ "LDAP_USER_ENABLED_DEFAULT": config_ldap.ldap_user_enabled_default,
+ "LDAP_USER_ENABLED_INVERT": str(config_ldap.ldap_user_enabled_invert),
+ "LDAP_GROUP_OBJECTCLASS": config_ldap.ldap_group_objectclass,
+ }
+ )
+ if config_ldap.ldap_use_starttls:
+ environment.update(
+ {
+ "LDAP_USE_STARTTLS": str(config_ldap.ldap_use_starttls),
+ "LDAP_TLS_CACERT_BASE64": config_ldap.ldap_tls_cacert_base64,
+ "LDAP_TLS_REQ_CERT": config_ldap.ldap_tls_req_cert,
+ }
+ )
+ optional_ldap_configs = {
+ "LDAP_BIND_USER": config_ldap.ldap_bind_user,
+ "LDAP_BIND_PASSWORD": config_ldap.ldap_bind_password,
+ "LDAP_USER_TREE_DN": config_ldap.ldap_user_tree_dn,
+ "LDAP_USER_FILTER": config_ldap.ldap_user_filter,
+ "LDAP_USER_ENABLED_ATTRIBUTE": config_ldap.ldap_user_enabled_attribute,
+ "LDAP_CHASE_REFERRALS": config_ldap.ldap_chase_referrals,
+ "LDAP_GROUP_TREE_DN": config_ldap.ldap_group_tree_dn,
+ "LDAP_TLS_CACERT_BASE64": config_ldap.ldap_tls_cacert_base64,
+ }
+ for env, value in optional_ldap_configs.items():
+ if value:
+ environment[env] = value
+ return environment
+
+
+class ConfigModel(ConfigValidator):
+ """Keystone Configuration."""
+
+ region_id: str
+ keystone_db_password: str
+ admin_username: str
+ admin_password: str
+ admin_project: str
+ service_username: str
+ service_password: str
+ service_project: str
+ user_domain_name: str
+ project_domain_name: str
+ token_expiration: int
+ mysql_uri: Optional[str]
+
+
+class ConfigLdapModel(ConfigValidator):
+ """LDAP Configuration."""
+
+ ldap_enabled: bool
+ ldap_authentication_domain_name: Optional[str]
+ ldap_url: Optional[str]
+ ldap_bind_user: Optional[str]
+ ldap_bind_password: Optional[str]
+ ldap_chase_referrals: Optional[str]
+ ldap_page_size: Optional[int]
+ ldap_user_tree_dn: Optional[str]
+ ldap_user_objectclass: Optional[str]
+ ldap_user_id_attribute: Optional[str]
+ ldap_user_name_attribute: Optional[str]
+ ldap_user_pass_attribute: Optional[str]
+ ldap_user_filter: Optional[str]
+ ldap_user_enabled_attribute: Optional[str]
+ ldap_user_enabled_mask: Optional[int]
+ ldap_user_enabled_default: Optional[str]
+ ldap_user_enabled_invert: Optional[bool]
+ ldap_group_objectclass: Optional[str]
+ ldap_group_tree_dn: Optional[str]
+ ldap_use_starttls: Optional[bool]
+ ldap_tls_cacert_base64: Optional[str]
+ ldap_tls_req_cert: Optional[str]
diff --git a/installers/charm/osm-keystone/src/interfaces.py b/installers/charm/osm-keystone/src/interfaces.py
new file mode 100644
index 0000000..7b019dd
--- /dev/null
+++ b/installers/charm/osm-keystone/src/interfaces.py
@@ -0,0 +1,190 @@
+# 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.
+
+"""Interfaces used by this charm."""
+
+import ops.charm
+import ops.framework
+import ops.model
+
+
+class BaseRelationClient(ops.framework.Object):
+ """Requires side of a Kafka Endpoint."""
+
+ def __init__(
+ self,
+ charm: ops.charm.CharmBase,
+ relation_name: str,
+ mandatory_fields: list = [],
+ ):
+ super().__init__(charm, relation_name)
+ self.relation_name = relation_name
+ self.mandatory_fields = mandatory_fields
+ self._update_relation()
+
+ def get_data_from_unit(self, key: str):
+ """Get data from unit relation data."""
+ if not self.relation:
+ # This update relation doesn't seem to be needed, but I added it because apparently
+ # the data is empty in the unit tests.
+ # In reality, the constructor is called in every hook.
+ # In the unit tests when doing an update_relation_data, apparently it is not called.
+ self._update_relation()
+ if self.relation:
+ for unit in self.relation.units:
+ data = self.relation.data[unit].get(key)
+ if data:
+ return data
+
+ def get_data_from_app(self, key: str):
+ """Get data from app relation data."""
+ if not self.relation or self.relation.app not in self.relation.data:
+ # This update relation doesn't seem to be needed, but I added it because apparently
+ # the data is empty in the unit tests.
+ # In reality, the constructor is called in every hook.
+ # In the unit tests when doing an update_relation_data, apparently it is not called.
+ self._update_relation()
+ if self.relation and self.relation.app in self.relation.data:
+ data = self.relation.data[self.relation.app].get(key)
+ if data:
+ return data
+
+ def is_missing_data_in_unit(self):
+ """Check if mandatory fields are present in any of the unit's relation data."""
+ return not all([self.get_data_from_unit(field) for field in self.mandatory_fields])
+
+ def is_missing_data_in_app(self):
+ """Check if mandatory fields are set in relation data."""
+ return not all([self.get_data_from_app(field) for field in self.mandatory_fields])
+
+ def _update_relation(self):
+ self.relation = self.framework.model.get_relation(self.relation_name)
+
+
+class MysqlClient(BaseRelationClient):
+ """Requires side of a Mysql Endpoint."""
+
+ mandatory_fields = ["host", "port", "user", "password", "root_password"]
+
+ def __init__(self, charm: ops.charm.CharmBase, relation_name: str):
+ super().__init__(charm, relation_name, self.mandatory_fields)
+
+ @property
+ def host(self):
+ """Host."""
+ return self.get_data_from_unit("host")
+
+ @property
+ def port(self):
+ """Port."""
+ return self.get_data_from_unit("port")
+
+ @property
+ def user(self):
+ """User."""
+ return self.get_data_from_unit("user")
+
+ @property
+ def password(self):
+ """Password."""
+ return self.get_data_from_unit("password")
+
+ @property
+ def root_password(self):
+ """Root password."""
+ return self.get_data_from_unit("root_password")
+
+ @property
+ def database(self):
+ """Database."""
+ return self.get_data_from_unit("database")
+
+ def get_root_uri(self, database: str):
+ """Get the URI for the mysql connection with the root user credentials.
+
+ Args:
+ database: Database name
+
+ Return:
+ A string with the following format:
+ mysql://root:<root_password>@<mysql_host>:<mysql_port>/<database>
+ """
+ return "mysql://root:{}@{}:{}/{}".format(
+ self.root_password, self.host, self.port, database
+ )
+
+ def get_uri(self):
+ """Get the URI for the mysql connection with the standard user credentials.
+
+ Args:
+ database: Database name
+ Return:
+ A string with the following format:
+ mysql://<user>:<password>@<mysql_host>:<mysql_port>/<database>
+ """
+ return "mysql://{}:{}@{}:{}/{}".format(
+ self.user, self.password, self.host, self.port, self.database
+ )
+
+
+class KeystoneServer(ops.framework.Object):
+ """Provides side of a Keystone Endpoint."""
+
+ relation_name: str = None
+
+ def __init__(self, charm: ops.charm.CharmBase, relation_name: str):
+ super().__init__(charm, relation_name)
+ self.relation_name = relation_name
+
+ def publish_info(
+ self,
+ host: str,
+ port: int,
+ user_domain_name: str,
+ project_domain_name: str,
+ username: str,
+ password: str,
+ service: str,
+ keystone_db_password: str,
+ region_id: str,
+ admin_username: str,
+ admin_password: str,
+ admin_project_name: str,
+ ):
+ """Publish information in Keystone relation."""
+ if self.framework.model.unit.is_leader():
+ for relation in self.framework.model.relations[self.relation_name]:
+ relation_data = relation.data[self.framework.model.app]
+ relation_data["host"] = str(host)
+ relation_data["port"] = str(port)
+ relation_data["user_domain_name"] = str(user_domain_name)
+ relation_data["project_domain_name"] = str(project_domain_name)
+ relation_data["username"] = str(username)
+ relation_data["password"] = str(password)
+ relation_data["service"] = str(service)
+ relation_data["keystone_db_password"] = str(keystone_db_password)
+ relation_data["region_id"] = str(region_id)
+ relation_data["admin_username"] = str(admin_username)
+ relation_data["admin_password"] = str(admin_password)
+ relation_data["admin_project_name"] = str(admin_project_name)