blob: c368ade3453511387664655c55cff19f64f7bbc7 [file] [log] [blame]
#!/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)