Repository: jupyterhub/zero-to-jupyterhub-k8s

Issue:
Generate passwords etc. into a dedicated k8s Secret that can be re-used
<!-- Thank you for contributing. These HTML comments will not render in the issue, but you can delete them once you've read them if you prefer! -->

### Proposed change
<!-- Use this section to describe the feature you'd like to be added. -->
Split hub-secret into two different secrets, one containing the ` values.yaml ` key and generated from the Helm config, the other would hold the `proxy.token` and  `cookie.secret` and can be set via ` existingSecret `.

Following commit 8fab275f1400f1f1c603ca06d621b25c2082a064 , the values.yaml are pulled into the secret. That’s an annoyance if you are trying to manage secrets encrypted in version control via Mozilla SOPS or Bitnami SealedSecrets. Ideally, you would like not to have to re-encrypt every single innocuous change to the ` values.yaml ` and instead keep it free of any sensitive content, so it can go into version control as clear text. 
That’s also a potential breaking change for users who upgrade the chart, since the `existingSecret` needs to be replaced. It is also annoying not be able to have direct access to your values in git.

### Alternative options
<!-- Use this section to describe alternative options and why you've decided on the proposed feature above. -->


### Who would use this feature?
<!-- Describe who would benefit from using this feature. -->
Anyone using the existingSecret option / using similar Gitops tools to manage Secrets.

### (Optional): Suggest a solution
<!-- Describe what you think needs to be done. Doing that is an excellent first step to get the feature implemented. -->
See proposed changes

Take [this file](https://github.com/jupyterhub/zero-to-jupyterhub-k8s/blob/master/jupyterhub/templates/hub/secret.yaml):

- always create no matter what a `hub-secret` Secret  with lines 10-16 and if and only if `existingSecret ` doesn’t exist add lines 18-35 to that secret (move line 1 to line 17)
- switch the templates secretKeyRef to ` {{hub-secret}} ` and only use ` {{ .Values.hub.existingSecret | default "hub-secret" }} ` for `proxy.token` and other keys from lines 18-35
- switch the templates condition;  instead of using only ` hub.db.password ` add a `.Values.hub.db.existingSecretHoldsPassword`  Boolean flag. A line like https://github.com/jupyterhub/zero-to-jupyterhub-k8s/blob/605cebc07dca36aa55cdf8e68c6bc811dcd02cda/jupyterhub/templates/hub/deployment.yaml#L171 becomes `    {{- if or .Values.hub.db.password   .Values.hub.db.existingSecretHoldsPassword}} ` 
- if you still want to keep the possibility to encrypt the values.yaml, add a new Boolean option `Values.hub.existingSecretHoldsEncryptedValues` and use that as the ‘ if condition’ on line 1. Users should then call their secret `hub-secret`, or better the Chart could use a user supplied variable allowing other secret name than `hub-secret` 


Discussion:
Note that this issue is closely related to https://github.com/jupyterhub/zero-to-jupyterhub-k8s/issues/1017 where I realized we can make use of the ideas in https://github.com/helm/charts/issues/5167#issuecomment-619137759. The latter describes the technical implementation of generating a secret that can be re-used with `helm` without introducing dedicated in-k8s-pod logic. This suggests the idea that when we generate the secret content, that we would also split the secret in two to make it easier to reference an old version of the autogenerated parts. I think it is a great idea!

> Following commit 8fab275 , the values.yaml are pulled into the secret. That’s an annoyance if you are trying to manage secrets encrypted in version control via Mozilla SOPS or Bitnami SealedSecrets. Ideally, you would like not to have to re-encrypt every single innocuous change to the values.yaml and instead keep it free of any sensitive content, so it can go into version control as clear text.

You can use two separate files, secret-values.yaml and values.yaml, but your request is relevant anyhow. It would be nice if you simply didn't provide proxy.secretToken etc manually through Helm values, but instead generated them and kept the generated ones in the k8s cluster under a default name or allowed the user to point to previously generated values in another Helm secret resource. I think we should do this, but, only when we drop support entirely for Helm2 users, which may be a good thing to do about now because _today_ Helm2 reached end of life.

Thank you for your thorough write-up @cyrilcros :heart:

Resolving patch:
diff --git a/jupyterhub/files/hub/jupyterhub_config.py b/jupyterhub/files/hub/jupyterhub_config.py
--- a/jupyterhub/files/hub/jupyterhub_config.py
+++ b/jupyterhub/files/hub/jupyterhub_config.py
@@ -12,7 +12,13 @@
 configuration_directory = os.path.dirname(os.path.realpath(__file__))
 sys.path.insert(0, configuration_directory)
 
-from z2jh import get_config, set_config_if_not_none, get_name, get_name_env
+from z2jh import (
+    get_config,
+    set_config_if_not_none,
+    get_name,
+    get_name_env,
+    get_secret_value,
+)
 
 
 def camelCaseify(s):
@@ -66,7 +72,6 @@ def camelCaseify(s):
     ("concurrent_spawn_limit", None),
     ("active_server_limit", None),
     ("base_url", None),
-    # ('cookie_secret', None),  # requires a Hex -> Byte transformation
     ("allow_named_servers", None),
     ("named_server_limit_per_user", None),
     ("authenticate_prometheus", None),
@@ -79,11 +84,6 @@ def camelCaseify(s):
         cfg_key = camelCaseify(trait)
     set_config_if_not_none(c.JupyterHub, trait, "hub." + cfg_key)
 
-# a required Hex -> Byte transformation
-cookie_secret_hex = get_config("hub.cookieSecret")
-if cookie_secret_hex:
-    c.JupyterHub.cookie_secret = a2b_hex(cookie_secret_hex)
-
 # hub_bind_url configures what the JupyterHub process within the hub pod's
 # container should listen to.
 hub_container_port = 8081
@@ -372,9 +372,19 @@ def camelCaseify(s):
     c.Spawner.debug = True
 
 
-# load hub.config values
+# load potentially seeded secrets
+c.JupyterHub.proxy_auth_token = get_secret_value("JupyterHub.proxy_auth_token")
+c.JupyterHub.cookie_secret = a2b_hex(get_secret_value("JupyterHub.cookie_secret"))
+c.CryptKeeper.keys = get_secret_value("CryptKeeper.keys").split(";")
+
+# load hub.config values, except potentially seeded secrets
 for section, sub_cfg in get_config("hub.config", {}).items():
-    c[section].update(sub_cfg)
+    if section == "JupyterHub" and sub_cfg in ["proxy_auth_token", "cookie_secret"]:
+        pass
+    elif section == "CryptKeeper" and sub_cfg in ["keys"]:
+        pass
+    else:
+        c[section].update(sub_cfg)
 
 # execute hub.extraConfig string
 extra_config = get_config("hub.extraConfig", {})
diff --git a/jupyterhub/files/hub/z2jh.py b/jupyterhub/files/hub/z2jh.py
--- a/jupyterhub/files/hub/z2jh.py
+++ b/jupyterhub/files/hub/z2jh.py
@@ -36,6 +36,18 @@ def _get_config_value(key):
         raise Exception(f"{path} not found!")
 
 
+@lru_cache()
+def get_secret_value(key):
+    """Load value from the k8s Secret given a key."""
+
+    path = f"/etc/jupyterhub/secret/{key}"
+    if os.path.exists(path):
+        with open(path) as f:
+            return f.read()
+    else:
+        raise Exception(f"{path} not found!")
+
+
 def get_name(name):
     """Returns the fullname of a resource given its short name"""
     return _get_config_value(name)


Test patch: