Repository: thelastpickle/cassandra-medusa

Issue:
Allow Medusa to take CQL credentials through a file
The K8ssandra-operator modular secrets backend requires the ability for Medusa to read CQL credentials from files. Currently only environment variables are supported.



┆Issue is synchronized with this [Jira Task](https://k8ssandra.atlassian.net/browse/K8SSAND-1619) by [Unito](https://www.unito.io)
┆friendlyId: K8SSAND-1619
┆priority: Medium


Comments:

Patch:
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 6fa77f1..2f02795 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -10,9 +10,14 @@ Modify it to match your requirements:
 ;config_file = <path to cassandra.yaml. Defaults to /etc/cassandra/cassandra.yaml>
 ;cql_username = <username>
 ;cql_password = <password>
+; When using the following setting there must be files in:
+; - `<cql_k8s_secrets_path>/username` containing username
+; - `<cql_k8s_secrets_path>/password` containing password
+;cql_k8s_secrets_path = <path to kubernetes secrets folder>
 ;nodetool_username =  <my nodetool username>
 ;nodetool_password =  <my nodetool password>
 ;nodetool_password_file_path = <path to nodetool password file>
+;nodetool_k8s_secrets_path = <path to nodetool kubernetes secrets folder>
 ;nodetool_host = <host name or IP to use for nodetool>
 ;nodetool_port = <port number to use for nodetool>
 ;certfile= <Client SSL: path to rootCa certificate>
@@ -153,12 +158,14 @@ backup_grace_period_in_days = 10
 
 Some config settings can be overriden through environment variables prefixed with `MEDUSA_`:
 
-| Setting                | Env Variable                  |
-|------------------------|-------------------------------|
-| `cql_username`         | `MEDUSA_CQL_USERNAME`         |
-| `cql_password`         | `MEDUSA_CQL_PASSWORD`         |
-| `nodetool_username`    | `MEDUSA_NODETOOL_USERNAME`    |
-| `nodetool_password`    | `MEDUSA_NODETOOL_PASSWORD`    |
-| `sstableloader_tspw`   | `MEDUSA_SSTABLELOADER_TSPW`   |
-| `sstableloader_kspw`   | `MEDUSA_SSTABLELOADER_KSPW`   |
-| `resolve_ip_addresses` | `MEDUSA_RESOLVE_IP_ADDRESSES` |
+| Setting                     | Env Variable                       |
+|-----------------------------|------------------------------------|
+| `cql_username`              | `MEDUSA_CQL_USERNAME`              |
+| `cql_password`              | `MEDUSA_CQL_PASSWORD`              |
+| `cql_k8s_secrets_path`      | `MEDUSA_CQL_K8S_SECRETS_PATH`      |
+| `nodetool_username`         | `MEDUSA_NODETOOL_USERNAME`         |
+| `nodetool_password`         | `MEDUSA_NODETOOL_PASSWORD`         |
+| `nodetool_k8s_secrets_path` | `MEDUSA_NODETOOL_K8S_SECRETS_PATH` |
+| `sstableloader_tspw`        | `MEDUSA_SSTABLELOADER_TSPW`        |
+| `sstableloader_kspw`        | `MEDUSA_SSTABLELOADER_KSPW`        |
+| `resolve_ip_addresses`      | `MEDUSA_RESOLVE_IP_ADDRESSES`      |
diff --git a/medusa-example.ini b/medusa-example.ini
index 715d825..e7a4e2d 100644
--- a/medusa-example.ini
+++ b/medusa-example.ini
@@ -18,9 +18,14 @@
 ;config_file = <path to cassandra.yaml. Defaults to /etc/cassandra/cassandra.yaml>
 ;cql_username = <username>
 ;cql_password = <password>
+; When using the following setting there must be files in:
+; - `<cql_k8s_secrets_path>/username` containing username
+; - `<cql_k8s_secrets_path>/password` containing password
+;cql_k8s_secrets_path = <path to kubernetes secrets folder>
 ;nodetool_username =  <my nodetool username>
 ;nodetool_password =  <my nodetool password>
 ;nodetool_password_file_path = <path to nodetool password file>
+;nodetool_k8s_secrets_path = <path to nodetool kubernetes secrets folder>
 ;nodetool_host = <host name or IP to use for nodetool>
 ;nodetool_port = <port number to use for nodetool>
 ;certfile= <Client SSL: path to rootCa certificate>
diff --git a/medusa/config.py b/medusa/config.py
index 0ecdfd3..e95ac0a 100644
--- a/medusa/config.py
+++ b/medusa/config.py
@@ -39,7 +39,8 @@ CassandraConfig = collections.namedtuple(
     ['start_cmd', 'stop_cmd', 'config_file', 'cql_username', 'cql_password', 'check_running', 'is_ccm',
      'sstableloader_bin', 'nodetool_username', 'nodetool_password', 'nodetool_password_file_path', 'nodetool_host',
      'nodetool_port', 'certfile', 'usercert', 'userkey', 'sstableloader_ts', 'sstableloader_tspw',
-     'sstableloader_ks', 'sstableloader_kspw', 'nodetool_ssl', 'resolve_ip_addresses', 'use_sudo', 'nodetool_flags']
+     'sstableloader_ks', 'sstableloader_kspw', 'nodetool_ssl', 'resolve_ip_addresses', 'use_sudo', 'nodetool_flags',
+     'cql_k8s_secrets_path', 'nodetool_k8s_secrets_path']
 )
 
 SSHConfig = collections.namedtuple(
@@ -229,12 +230,30 @@ def parse_config(args, config_file):
         'nodetool_password',
         'sstableloader_tspw',
         'sstableloader_kspw',
-        'resolve_ip_addresses'
+        'resolve_ip_addresses',
+        'cql_k8s_secrets_path',
+        'nodetool_k8s_secrets_path'
     ]:
         config_property_upper = "MEDUSA_{}".format(config_property.upper())
         if config_property_upper in os.environ:
             config.set('cassandra', config_property, os.environ[config_property_upper])
 
+    if config.has_option('cassandra', 'cql_k8s_secrets_path'):
+        cql_k8s_secrets_path = config.get('cassandra', 'cql_k8s_secrets_path')
+        if cql_k8s_secrets_path:
+            logging.debug('Using cql_k8s_secrets_path (path="{}")'.format(cql_k8s_secrets_path))
+            cql_k8s_username, cql_k8s_password = _load_k8s_secrets(cql_k8s_secrets_path)
+            config.set('cassandra', 'cql_username', cql_k8s_username)
+            config.set('cassandra', 'cql_password', cql_k8s_password)
+
+    if config.has_option('cassandra', 'nodetool_k8s_secrets_path'):
+        nodetool_k8s_secrets_path = config.get('cassandra', 'nodetool_k8s_secrets_path')
+        if nodetool_k8s_secrets_path:
+            logging.debug('Using nodetool_k8s_secrets_path (path="{}")'.format(nodetool_k8s_secrets_path))
+            nodetool_k8s_username, nodetool_k8s_password = _load_k8s_secrets(nodetool_k8s_secrets_path)
+            config.set('cassandra', 'nodetool_username', nodetool_k8s_username)
+            config.set('cassandra', 'nodetool_password', nodetool_k8s_password)
+
     resolve_ip_addresses = config['cassandra']['resolve_ip_addresses']
     hostname_resolver = HostnameResolver(resolve_ip_addresses, kubernetes_enabled)
     if config['storage']['fqdn'] == socket.getfqdn() and not resolve_ip_addresses:
@@ -249,6 +268,27 @@ def parse_config(args, config_file):
     return config
 
 
+def _load_k8s_secrets(k8s_secrets_path):
+    """Load username and password from files following the k8s secrets convention.
+
+    :param str k8s_secrets_path: folder path containing the secrets
+    :return str, str: username and password contained in files
+    """
+    # By default, username and password are available in path/username and path/password.
+    # They could be in other places if overridden, this is not supported for now. Refs:
+    # https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-files-from-a-pod
+    # https://kubernetes.io/docs/concepts/configuration/secret/#consuming-secret-values-from-volumes
+    k8s_username_file = os.path.join(k8s_secrets_path, 'username')
+    logging.debug('Loading k8s username from "{}"'.format(k8s_username_file))
+    with open(k8s_username_file, 'r') as f:
+        k8s_username = f.read().strip()
+    k8s_password_file = os.path.join(k8s_secrets_path, 'password')
+    logging.debug('Loading k8s password from "{}"'.format(k8s_password_file))
+    with open(k8s_password_file, 'r') as f:
+        k8s_password = f.read().strip()
+    return k8s_username, k8s_password
+
+
 def load_config(args, config_file):
     """Load configuration from a medusa.ini file
 


Test patch:
diff --git a/tests/config_test.py b/tests/config_test.py
index 56acc04..3c4ead6 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -18,6 +18,7 @@ import pathlib
 import unittest
 from unittest.mock import patch
 import socket
+import tempfile
 
 import medusa.config
 import medusa.utils
@@ -98,6 +99,44 @@ class ConfigTest(unittest.TestCase):
         assert config.cassandra.cql_username == 'new_cql_username'
         assert config.cassandra.cql_password == 'new_cql_password'
 
+    def test_cql_k8s_secrets_path_override(self):
+        """
+        Ensure that CQL credentials stored in a path following k8s convention override the default vars.
+        """
+        tmpdir = tempfile.mkdtemp()
+        os.environ['MEDUSA_CQL_K8S_SECRETS_PATH'] = tmpdir
+        # Write k8s_username and k8s_password in /tmpdir/username and /tmpdir/password
+        for k8s_cred in ['username', 'password']:
+            with open(os.path.join(tmpdir, k8s_cred), 'w') as f:
+                f.write('k8s_{}'.format(k8s_cred))
+
+        args = {}
+        config = medusa.config.load_config(args, self.medusa_config_file)
+        assert config.cassandra.cql_username == 'k8s_username'
+        assert config.cassandra.cql_password == 'k8s_password'
+
+        # Cleanup
+        os.environ.pop('MEDUSA_CQL_K8S_SECRETS_PATH', None)
+
+    def test_nodetool_k8s_secrets_path_override(self):
+        """
+        Ensure that nodetool credentials stored in a path following k8s convention override the default vars.
+        """
+        tmpdir = tempfile.mkdtemp()
+        os.environ['MEDUSA_NODETOOL_K8S_SECRETS_PATH'] = tmpdir
+        # Write nodetool_username and nodetool_password in /tmpdir/username and /tmpdir/password
+        for k8s_cred in ['username', 'password']:
+            with open(os.path.join(tmpdir, k8s_cred), 'w') as f:
+                f.write('k8s_{}'.format(k8s_cred))
+
+        args = {}
+        config = medusa.config.load_config(args, self.medusa_config_file)
+        assert config.cassandra.nodetool_username == 'k8s_username'
+        assert config.cassandra.nodetool_password == 'k8s_password'
+
+        # Cleanup
+        os.environ.pop('MEDUSA_NODETOOL_K8S_SECRETS_PATH', None)
+
     def test_args_settings_override(self):
         """Ensure that each config file's section settings can be overridden with command line options"""
         args = {