Repository: jupyterhub/zero-to-jupyterhub-k8s

Issue:
kubespawners deprecation of singleuser_/user_ prefixes
## Background
The `c.KubeSpawner.singleuser_extra_pod_config` traitlet is deprecated, along with all traitlets prefixed `singleuser_`. To remain backwards compatible kubespawner observes the fields with singleuser prefix and updates a new field instead using `setattr(self, _new_name, change.new)`.

<details>
 <summary>jupyterhub/kubespawner : spawner.py</summary>

```python
from traitlets import Any, Unicode, List, Integer, Union, Dict, Bool, Any, observe

# ... ... ...

class KubeSpawner(Spawner):
    """
    Implement a JupyterHub spawner to spawn pods in a Kubernetes Cluster.
    """
    
    # ... ... ...

    extra_pod_config = Dict(
        None,
        config=True,
        help="""
        Extra configuration (e.g. tolerations) for the pod which is not covered by other attributes.

        This dict will be directly merge into pod,so you should use the same structure.
        Each item in the dict is field of pod configuration
        which follows spec at https://v1-6.docs.kubernetes.io/docs/api-reference/v1.6/#podspec-v1-core.

        One usage is set dnsPolicy with configuration below::

            dnsPolicy: ClusterFirstWithHostNet

        The `key` could be either camelcase word (used by Kubernetes yaml, e.g. `dnsPolicy`)
        or underscore-separated word (used by kubernetes python client, e.g. `dns_policy`).
        """
    )

    # ... ... ...

    # deprecate redundant and inconsistent singleuser_ and user_ prefixes:
    _deprecated_traits = [
        "singleuser_working_dir",
        "singleuser_service_account",
        "singleuser_extra_labels",
        "singleuser_extra_annotations",
        "singleuser_image_spec",
        "singleuser_image_pull_policy",
        "singleuser_image_pull_secrets",
        "singleuser_node_selector",
        "singleuser_uid",
        "singleuser_fs_gid",
        "singleuser_supplemental_gids",
        "singleuser_privileged",
        "singleuser_lifecycle_hooks",
        "singleuser_extra_pod_config",
        "singleuser_init_containers",
        "singleuser_extra_container_config",
        "singleuser_extra_containers",
        "user_storage_class",
        "user_storage_pvc_ensure",
        "user_storage_capacity",
        "user_storage_extra_labels",
        "user_storage_access_modes",
    ]
    # define Any traits for deprecated names
    # so we can propagate their values to the new traits
    for _deprecated_name in _deprecated_traits:
        _new_name = _deprecated_name.split('_', 1)[1]
        exec(
            "{} = Any(config=True, help='DEPRECATED. Use {}.')".format(
                _deprecated_name, _new_name
            )
        )
    del _deprecated_name, _new_name

    @observe(*_deprecated_traits)
    def _deprecated_trait_changed(self, change):
        """Warn on use of deprecated config traits

        preserving behavior by propagating values to the new name
        """
        # new name without prefix:
        _new_name = change.name.split('_', 1)[1]
        # warn about the deprecated name
        warnings.warn(
            "KubeSpawner.{} is deprecated in 0.9. Use KubeSpawner.{}".format(
                change.name, _new_name,
            ),
            DeprecationWarning,
            stacklevel=2,
        )
        # assign to the real attribute
        setattr(self, _new_name, change.new)
```
</details>
<br/>
In this helm chart, we (chart maintainers) setup the JupyterHub pod with [a `jupyterhub_config.py` that will make some *initial configuration*](https://github.com/jupyterhub/zero-to-jupyterhub-k8s/blob/master/images/hub/jupyterhub_config.py). Then later a chart user (cluster administrators) might make *additional configuration* that are added. @kalugny realized (#740) that using `singleuser_extra_pod_config` worked in the *additional configuration* while `extra_pod_config` did not work, and that was unintentionally reproduced by me as well :sob:.

## Theory 1
Perhaps the *initial configuration* on `singleuser_extra_pod_config` is [`@observe`ed by the traitlets library](https://github.com/ipython/traitlets#callbacks-when-a-trait-attribute-changes) but the reaction will happen after the *additional configuration*, causing the value set on `extra_pod_config` in the additional configuration to be overrided by the value set to `singleuser_extra_pod_config` in the initial configuration.

```python
# the last lines of the initial configuration in `jupyterhub_config.py` used in the z2jh's hub image
extra_configs = sorted(glob.glob('/etc/jupyterhub/config/hub.extra-config.*.py'))
for ec in extra_configs:
    load_subconfig(ec)
```

## Theory 2
Perhaps the `setattr` command replaces the `extra_pod_config` traitlet objects with a normal Dict, causing some trouble somehow...
```python
        setattr(self, _new_name, change.new)
```

## Solution idea
We update the [initial configuration](https://github.com/jupyterhub/zero-to-jupyterhub-k8s/blob/master/images/hub/jupyterhub_config.py) to not use the `singleuser_` prefix on kubespawners traitlets. If *Theory 1* is correct, it means that the initial configuration using the deprecated syntax overruled things, and the change will simply allow additional configuration to work. I think it would be a safe change, but I'm not 100%.

## Reference
#### The deprecated prefixes (`singleuser_` and `user_`) is used in the initial configuration for...
```
# mashed code from the initial configuration
singleuser_image_spec = os.environ['SINGLEUSER_IMAGE']
singleuser_image_pull_policy = get_config('singleuser.image-pull-policy')
singleuser_extra_labels = get_config('singleuser.extra-labels', {})
singleuser_uid = get_config('singleuser.uid')
singleuser_fs_gid = get_config('singleuser.fs-gid')
singleuser_service_account = service_account_name
singleuser_node_selector = get_config('singleuser.node-selector')
singleuser_lifecycle_hooks = lifecycle_hooks
singleuser_init_containers.extend(init_containers)
singleuser_init_containers.append(ip_block_container)
singleuser_extra_pod_config = { ... }
user_storage_pvc_ensure = True
user_storage_class = storage_class
user_storage_access_modes = get_config('singleuser.storage.dynamic.storage-access-modes')
user_storage_capacity = get_config('singleuser.storage.capacity')
```
/cc: @minrk @yuvipanda - Does Theory 1 make sense? Does the solution idea seem safe to implement?


Discussion:
I think if config options have become deprecated in kubespawner we should update this chart to not use the deprecated versions any more. (Even if this doesn't fix the bug reported here.)
That would leave us users totally stranded...
At least now we have a work-around (even if unintentionally)
@betatim thanks for helping me process this! I'm thinking it the change will be fine as long as *Theory 1* is correct, I think I'll verify the behavior experimentally.

@kalugny I think the change will make you able to use `singleuser_extra_pod_config` along with `extra_pod_config` while previously you and me both ended up confused about `extra_pod_config` not working while `singleuser_extra_pod_config` ended up working. I really appreciate that you wrote #740, I would had spent so much more time stuck on this bug without understanding it was related to the `singleuser_` prefix unless you had battled this issue already! :heart:

Resolving patch:
diff --git a/images/hub/jupyterhub_config.py b/images/hub/jupyterhub_config.py
--- a/images/hub/jupyterhub_config.py
+++ b/images/hub/jupyterhub_config.py
@@ -44,32 +44,32 @@
 c.KubeSpawner.start_timeout = get_config('singleuser.start-timeout')
 
 # Use env var for this, since we want hub to restart when this changes
-c.KubeSpawner.singleuser_image_spec = os.environ['SINGLEUSER_IMAGE']
+c.KubeSpawner.image_spec = os.environ['SINGLEUSER_IMAGE']
 
-c.KubeSpawner.singleuser_image_pull_policy = get_config('singleuser.image-pull-policy')
+c.KubeSpawner.image_pull_policy = get_config('singleuser.image-pull-policy')
 
-c.KubeSpawner.singleuser_extra_labels = get_config('singleuser.extra-labels', {})
+c.KubeSpawner.extra_labels = get_config('singleuser.extra-labels', {})
 
-c.KubeSpawner.singleuser_uid = get_config('singleuser.uid')
-c.KubeSpawner.singleuser_fs_gid = get_config('singleuser.fs-gid')
+c.KubeSpawner.uid = get_config('singleuser.uid')
+c.KubeSpawner.fs_gid = get_config('singleuser.fs-gid')
 
 service_account_name = get_config('singleuser.service-account-name', None)
 if service_account_name:
-    c.KubeSpawner.singleuser_service_account = service_account_name
+    c.KubeSpawner.service_account = service_account_name
 
-c.KubeSpawner.singleuser_node_selector = get_config('singleuser.node-selector')
+c.KubeSpawner.node_selector = get_config('singleuser.node-selector')
 # Configure dynamically provisioning pvc
 storage_type = get_config('singleuser.storage.type')
 if storage_type == 'dynamic':
     pvc_name_template = get_config('singleuser.storage.dynamic.pvc-name-template')
     volume_name_template = get_config('singleuser.storage.dynamic.volume-name-template')
     c.KubeSpawner.pvc_name_template = pvc_name_template
-    c.KubeSpawner.user_storage_pvc_ensure = True
+    c.KubeSpawner.storage_pvc_ensure = True
     storage_class = get_config('singleuser.storage.dynamic.storage-class', None)
     if storage_class:
-        c.KubeSpawner.user_storage_class = storage_class
-    c.KubeSpawner.user_storage_access_modes = get_config('singleuser.storage.dynamic.storage-access-modes')
-    c.KubeSpawner.user_storage_capacity = get_config('singleuser.storage.capacity')
+        c.KubeSpawner.storage_class = storage_class
+    c.KubeSpawner.storage_access_modes = get_config('singleuser.storage.dynamic.storage-access-modes')
+    c.KubeSpawner.storage_capacity = get_config('singleuser.storage.capacity')
 
     # Add volumes to singleuser pods
     c.KubeSpawner.volumes = [
@@ -106,11 +106,11 @@
 
 lifecycle_hooks = get_config('singleuser.lifecycle-hooks')
 if lifecycle_hooks:
-    c.KubeSpawner.singleuser_lifecycle_hooks = lifecycle_hooks
+    c.KubeSpawner.lifecycle_hooks = lifecycle_hooks
 
 init_containers = get_config('singleuser.init-containers')
 if init_containers:
-    c.KubeSpawner.singleuser_init_containers.extend(init_containers)
+    c.KubeSpawner.init_containers.extend(init_containers)
 
 # Gives spawned containers access to the API of the hub
 c.KubeSpawner.hub_connect_ip = os.environ['HUB_SERVICE_HOST']
@@ -315,13 +315,13 @@ def generate_user_name(spawner):
         )
     )
 
-    c.KubeSpawner.singleuser_init_containers.append(ip_block_container)
+    c.KubeSpawner.init_containers.append(ip_block_container)
 
 scheduler_strategy = get_config('singleuser.scheduler-strategy', 'spread')
 
 if scheduler_strategy == 'pack':
     # FIXME: Support setting affinity directly in KubeSpawner
-    c.KubeSpawner.singleuser_extra_pod_config = {
+    c.KubeSpawner.extra_pod_config = {
         'affinity': {
             'podAffinity': {
                 'preferredDuringSchedulingIgnoredDuringExecution': [{
@@ -352,9 +352,6 @@ def generate_user_name(spawner):
             }
         }
     }
-else:
-    # Set default to {} so subconfigs can easily update it
-    c.KubeSpawner.singleuser_extra_pod_config = {}
 
 if get_config('debug.enabled', False):
     c.JupyterHub.log_level = 'DEBUG'


Test patch: