Adding HA support for Keystone charm 24/9824/10
authorsousaedu <eduardo.sousa@canonical.com>
Fri, 9 Oct 2020 23:25:26 +0000 (00:25 +0100)
committersousaedu <eduardo.sousa@canonical.com>
Mon, 19 Oct 2020 14:03:38 +0000 (15:03 +0100)
Change-Id: I691154ef2952bc124d7ddb689e39455c213c2a4b
Signed-off-by: sousaedu <eduardo.sousa@canonical.com>
installers/charm/keystone/.gitignore
installers/charm/keystone/config.yaml
installers/charm/keystone/requirements.txt
installers/charm/keystone/src/charm.py

index 2545cca..97fc8b4 100644 (file)
@@ -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
index b014e55..1ad4785 100644 (file)
@@ -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
index 10ecdcd..5a4c0af 100644 (file)
@@ -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
index 8a5942a..23dfcb6 100755 (executable)
 #     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__":