From 738bf6fbc3f9f23307c519f8ef980e9975dcbd9d Mon Sep 17 00:00:00 2001 From: sousaedu Date: Sat, 10 Oct 2020 00:25:26 +0100 Subject: [PATCH] Adding HA support for Keystone charm Change-Id: I691154ef2952bc124d7ddb689e39455c213c2a4b Signed-off-by: sousaedu --- installers/charm/keystone/.gitignore | 1 + installers/charm/keystone/config.yaml | 4 + installers/charm/keystone/requirements.txt | 1 + installers/charm/keystone/src/charm.py | 195 ++++++++++++++++++--- 4 files changed, 179 insertions(+), 22 deletions(-) diff --git a/installers/charm/keystone/.gitignore b/installers/charm/keystone/.gitignore index 2545cca8..97fc8b44 100644 --- a/installers/charm/keystone/.gitignore +++ b/installers/charm/keystone/.gitignore @@ -12,5 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. .vscode +.tox build keystone.charm \ No newline at end of file diff --git a/installers/charm/keystone/config.yaml b/installers/charm/keystone/config.yaml index b014e55b..1ad4785b 100644 --- a/installers/charm/keystone/config.yaml +++ b/installers/charm/keystone/config.yaml @@ -93,6 +93,10 @@ options: description: | Project domain name (Hardcoded in the container start.sh script) default: default + token_expiration: + type: int + description: Token keys expiration in seconds + default: 172800 ldap_enabled: type: boolean description: Boolean to enable/disable LDAP authentication diff --git a/installers/charm/keystone/requirements.txt b/installers/charm/keystone/requirements.txt index 10ecdcd5..5a4c0afd 100644 --- a/installers/charm/keystone/requirements.txt +++ b/installers/charm/keystone/requirements.txt @@ -11,4 +11,5 @@ # 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. +cryptography ops diff --git a/installers/charm/keystone/src/charm.py b/installers/charm/keystone/src/charm.py index 8a5942af..23dfcb6f 100755 --- a/installers/charm/keystone/src/charm.py +++ b/installers/charm/keystone/src/charm.py @@ -13,13 +13,22 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import logging - +from datetime import datetime +from typing import ( + Any, + Dict, + List, + NoReturn, + Tuple, +) from urllib.parse import urlparse -from ops.charm import CharmBase +from cryptography.fernet import Fernet -# from ops.framework import StoredState +from ops.charm import CharmBase, EventBase +from ops.framework import StoredState from ops.main import main from ops.model import ( ActiveStatus, @@ -28,33 +37,53 @@ from ops.model import ( WaitingStatus, # ModelError, ) -from ops.framework import StoredState -logger = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) REQUIRED_SETTINGS = [] -DATABASE_NAME = "keystone" # This is hardcoded in the keystone container script +# This is hardcoded in the keystone container script +DATABASE_NAME = "keystone" + # We expect the keystone container to use the default port KEYSTONE_PORT = 5000 +# Number of keys need might need to be adjusted in the future +NUMBER_FERNET_KEYS = 2 +NUMBER_CREDENTIAL_KEYS = 2 + +# Path for keys +CREDENTIAL_KEYS_PATH = "/etc/keystone/credential-keys" +FERNET_KEYS_PATH = "/etc/keystone/fernet-keys" + class KeystoneCharm(CharmBase): + """Keystone K8s Charm""" state = StoredState() - def __init__(self, *args): + def __init__(self, *args) -> NoReturn: + """Constructor of the Charm object. + Initializes internal state and register events it can handle. + """ super().__init__(*args) + self.state.set_default(db_host=None) + self.state.set_default(db_port=None) + self.state.set_default(db_user=None) + self.state.set_default(db_password=None) + self.state.set_default(pod_spec=None) + self.state.set_default(fernet_keys=None) + self.state.set_default(credential_keys=None) + self.state.set_default(keys_timestamp=0) # Register all of the events we want to observe self.framework.observe(self.on.config_changed, self.configure_pod) self.framework.observe(self.on.start, self.configure_pod) self.framework.observe(self.on.upgrade_charm, self.configure_pod) + self.framework.observe(self.on.leader_elected, self.configure_pod) + self.framework.observe(self.on.update_status, self.configure_pod) # Register relation events - self.state.set_default( - db_host=None, db_port=None, db_user=None, db_password=None - ) self.framework.observe( self.on.db_relation_changed, self._on_db_relation_changed ) @@ -62,7 +91,13 @@ class KeystoneCharm(CharmBase): self.on.keystone_relation_joined, self._publish_keystone_info ) - def _publish_keystone_info(self, event): + def _publish_keystone_info(self, event: EventBase) -> NoReturn: + """Publishes keystone information for NBI usage through the keystone + relation. + + Args: + event (EventBase): Keystone relation event to update NBI. + """ config = self.model.config if self.unit.is_leader(): rel_data = { @@ -82,7 +117,14 @@ class KeystoneCharm(CharmBase): for k, v in rel_data.items(): event.relation.data[self.model.unit][k] = v - def _on_db_relation_changed(self, event): + def _on_db_relation_changed(self, event: EventBase) -> NoReturn: + """Reads information about the DB relation, in order for keystone to + access it. + + Args: + event (EventBase): DB relation event to access database + information. + """ self.state.db_host = event.relation.data[event.unit].get("host") self.state.db_port = event.relation.data[event.unit].get("port", 3306) self.state.db_user = "root" # event.relation.data[event.unit].get("user") @@ -90,7 +132,12 @@ class KeystoneCharm(CharmBase): if self.state.db_host: self.configure_pod(event) - def _check_settings(self): + def _check_settings(self) -> str: + """Check if there any settings missing from Keystone configuration. + + Returns: + str: Information about the problems found (if any). + """ problems = [] config = self.model.config @@ -101,7 +148,12 @@ class KeystoneCharm(CharmBase): return ";".join(problems) - def _make_pod_image_details(self): + def _make_pod_image_details(self) -> Dict[str, str]: + """Generate the pod image details. + + Returns: + Dict[str, str]: pod image details. + """ config = self.model.config image_details = { "imagePath": config["image"], @@ -115,12 +167,22 @@ class KeystoneCharm(CharmBase): ) return image_details - def _make_pod_ports(self): + def _make_pod_ports(self) -> List[Dict[str, Any]]: + """Generate the pod ports details. + + Returns: + List[Dict[str, Any]]: pod ports details. + """ return [ {"name": "keystone", "containerPort": KEYSTONE_PORT, "protocol": "TCP"}, ] - def _make_pod_envconfig(self): + def _make_pod_envconfig(self) -> Dict[str, Any]: + """Generate pod environment configuraiton. + + Returns: + Dict[str, Any]: pod environment configuration. + """ config = self.model.config envconfig = { @@ -176,7 +238,12 @@ class KeystoneCharm(CharmBase): return envconfig - def _make_pod_ingress_resources(self): + def _make_pod_ingress_resources(self) -> List[Dict[str, Any]]: + """Generate pod ingress resources. + + Returns: + List[Dict[str, Any]]: pod ingress resources. + """ site_url = self.model.config["site_url"] if not site_url: @@ -238,18 +305,94 @@ class KeystoneCharm(CharmBase): return [ingress] - def configure_pod(self, event): - """Assemble the pod spec and apply it, if possible.""" + def _generate_keys(self) -> Tuple[List[str], List[str]]: + """Generating new fernet tokens. + + Returns: + Tuple[List[str], List[str]]: contains two lists of strings. First + list contains strings that represent + the keys for fernet and the second + list contains strins that represent + the keys for credentials. + """ + fernet_keys = [ + Fernet.generate_key().decode() for _ in range(NUMBER_FERNET_KEYS) + ] + credential_keys = [ + Fernet.generate_key().decode() for _ in range(NUMBER_CREDENTIAL_KEYS) + ] + return (fernet_keys, credential_keys) + + def _make_pod_files( + self, fernet_keys: List[str], credential_keys: List[str] + ) -> List[Dict[str, Any]]: + """Generating ConfigMap information. + + Args: + fernet_keys (List[str]): keys for fernet. + credential_keys (List[str]): keys for credentials. + + Returns: + List[Dict[str, Any]]: ConfigMap information. + """ + files = [ + { + "name": "fernet-keys", + "mountPath": FERNET_KEYS_PATH, + "files": [ + {"path": str(key_id), "content": value} + for (key_id, value) in enumerate(fernet_keys) + ], + } + ] + + files.append( + { + "name": "credential-keys", + "mountPath": CREDENTIAL_KEYS_PATH, + "files": [ + {"path": str(key_id), "content": value} + for (key_id, value) in enumerate(credential_keys) + ], + } + ) + + return files + + def configure_pod(self, event: EventBase) -> NoReturn: + """Assemble the pod spec and apply it, if possible. + + Args: + event (EventBase): Hook or Relation event that started the + function. + """ if not self.state.db_host: self.unit.status = WaitingStatus("Waiting for database relation") event.defer() return if not self.unit.is_leader(): - self.unit.status = ActiveStatus() + self.unit.status = ActiveStatus("ready") return + if fernet_keys := self.state.fernet_keys: + fernet_keys = json.loads(fernet_keys) + + if credential_keys := self.state.credential_keys: + credential_keys = json.loads(credential_keys) + + now = datetime.now().timestamp() + keys_timestamp = self.state.keys_timestamp + token_expiration = self.model.config["token_expiration"] + + valid_keys = (now - keys_timestamp) < token_expiration + if not credential_keys or not fernet_keys or not valid_keys: + fernet_keys, credential_keys = self._generate_keys() + self.state.fernet_keys = json.dumps(fernet_keys) + self.state.credential_keys = json.dumps(credential_keys) + self.state.keys_timestamp = now + # Check problems in the settings problems = self._check_settings() if problems: @@ -261,6 +404,7 @@ class KeystoneCharm(CharmBase): ports = self._make_pod_ports() env_config = self._make_pod_envconfig() ingress_resources = self._make_pod_ingress_resources() + files = self._make_pod_files(fernet_keys, credential_keys) pod_spec = { "version": 3, @@ -270,12 +414,19 @@ class KeystoneCharm(CharmBase): "imageDetails": image_details, "ports": ports, "envConfig": env_config, + "volumeConfig": files, } ], "kubernetesResources": {"ingressResources": ingress_resources or []}, } - self.model.pod.set_spec(pod_spec) - self.unit.status = ActiveStatus() + + if self.state.pod_spec != ( + pod_spec_json := json.dumps(pod_spec, sort_keys=True) + ): + self.state.pod_spec = pod_spec_json + self.model.pod.set_spec(pod_spec) + + self.unit.status = ActiveStatus("ready") if __name__ == "__main__": -- 2.17.1