diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eee8ebd800..2136f5bc04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -214,7 +214,7 @@ only rebuild images if their dependent files in their respective directories or 1. Use `helm` to upgrade (or install) your local JupyterHub Helm chart. ```shell - helm upgrade --install jupyterhub ./jupyterhub --cleanup-on-fail --values dev-config.yaml + helm upgrade --install jupyterhub ./jupyterhub --cleanup-on-fail --values dev-config.yaml --values dev-config-local-chart-extra-config.yaml ``` Note that `--cleanup-on-fail` is a very good practice to avoid ` already exist` errors in future upgrades following a failed upgrade. diff --git a/dev-config-local-chart-extra-config.yaml b/dev-config-local-chart-extra-config.yaml index ba5b301188..c0eeb14c7d 100644 --- a/dev-config-local-chart-extra-config.yaml +++ b/dev-config-local-chart-extra-config.yaml @@ -8,3 +8,14 @@ # include this config and then pass --set hub.some-option=null to null it out # when it must not be passed, but that still triggers schema validation errors. # + +singleuser: + storage: + extraVolumes: + test-volume: + name: test-volume + emptyDir: {} + extraVolumeMounts: + test-volume-mount: + name: test-volume + mountPath: /test-volume diff --git a/docs/source/jupyterhub/customizing/user-resources.md b/docs/source/jupyterhub/customizing/user-resources.md index 9d05b785d3..43bb726b84 100644 --- a/docs/source/jupyterhub/customizing/user-resources.md +++ b/docs/source/jupyterhub/customizing/user-resources.md @@ -118,11 +118,13 @@ The following configuration will increase the SHM allocation by mounting a singleuser: storage: extraVolumes: - - name: shm-volume + shm-volume: + name: shm-volume emptyDir: medium: Memory extraVolumeMounts: - - name: shm-volume + shm-volume: + name: shm-volume mountPath: /dev/shm ``` diff --git a/docs/source/jupyterhub/customizing/user-storage.md b/docs/source/jupyterhub/customizing/user-storage.md index af10449922..65f389f419 100644 --- a/docs/source/jupyterhub/customizing/user-storage.md +++ b/docs/source/jupyterhub/customizing/user-storage.md @@ -235,11 +235,13 @@ pods: singleuser: storage: extraVolumes: - - name: jupyterhub-shared + jupyterhub-shared: + name: jupyterhub-shared persistentVolumeClaim: claimName: jupyterhub-shared-volume extraVolumeMounts: - - name: jupyterhub-shared + jupyterhub-shared: + name: jupyterhub-shared mountPath: /home/shared ``` diff --git a/jupyterhub/files/hub/jupyterhub_config.py b/jupyterhub/files/hub/jupyterhub_config.py index ad742ed490..ac44fadd86 100644 --- a/jupyterhub/files/hub/jupyterhub_config.py +++ b/jupyterhub/files/hub/jupyterhub_config.py @@ -252,6 +252,9 @@ def camelCaseify(s): if tolerations: c.KubeSpawner.tolerations = tolerations +volumes = {} +volume_mounts = {} + # Configure dynamically provisioning pvc storage_type = get_config("singleuser.storage.type") if storage_type == "dynamic": @@ -273,32 +276,35 @@ def camelCaseify(s): ) # Add volumes to singleuser pods - c.KubeSpawner.volumes = [ - { + volumes = { + volume_name_template: { "name": volume_name_template, "persistentVolumeClaim": {"claimName": "{pvc_name}"}, } - ] - c.KubeSpawner.volume_mounts = [ - { + } + volume_mounts = { + volume_name_template: { "mountPath": get_config("singleuser.storage.homeMountPath"), "name": volume_name_template, "subPath": get_config("singleuser.storage.dynamic.subPath"), } - ] + } elif storage_type == "static": pvc_claim_name = get_config("singleuser.storage.static.pvcName") - c.KubeSpawner.volumes = [ - {"name": "home", "persistentVolumeClaim": {"claimName": pvc_claim_name}} - ] + volumes = { + "home": { + "name": "home", + "persistentVolumeClaim": {"claimName": pvc_claim_name}, + } + } - c.KubeSpawner.volume_mounts = [ - { + volume_mounts = { + "home": { "mountPath": get_config("singleuser.storage.homeMountPath"), "name": "home", "subPath": get_config("singleuser.storage.static.subPath"), } - ] + } # Inject singleuser.extraFiles as volumes and volumeMounts with data loaded from # the dedicated k8s Secret prepared to hold the extraFiles actual content. @@ -323,24 +329,39 @@ def camelCaseify(s): "secretName": get_name("singleuser"), "items": items, } - c.KubeSpawner.volumes.append(volume) + volumes.update({volume["name"]: volume}) - volume_mounts = [] - for file_key, file_details in extra_files.items(): - volume_mounts.append( - { - "mountPath": file_details["mountPath"], - "subPath": file_key, - "name": "files", - } - ) - c.KubeSpawner.volume_mounts.extend(volume_mounts) + for idx, (file_key, file_details) in enumerate(extra_files.items()): + volume_mount = { + "mountPath": file_details["mountPath"], + "subPath": file_key, + "name": "files", + } + volume_mounts.update({f"{idx}-files": volume_mount}) # Inject extraVolumes / extraVolumeMounts -c.KubeSpawner.volumes.extend(get_config("singleuser.storage.extraVolumes", [])) -c.KubeSpawner.volume_mounts.extend( - get_config("singleuser.storage.extraVolumeMounts", []) -) +extra_volumes = get_config("singleuser.storage.extraVolumes", default={}) +if isinstance(extra_volumes, dict): + for key, volume in extra_volumes.items(): + volumes.update({key: volume}) +elif isinstance(extra_volumes, list): + for volume in extra_volumes: + volumes.update({volume["name"]: volume}) + +extra_volume_mounts = get_config("singleuser.storage.extraVolumeMounts", default={}) +if isinstance(extra_volume_mounts, dict): + for key, volume_mount in extra_volume_mounts.items(): + volume_mounts.update({key: volume_mount}) +elif isinstance(extra_volume_mounts, list): + # If extraVolumeMounts is a list, we need to add them to the volume_mounts + # dictionary with a unique key. + # Since volume mount's name is not guaranteed to be unique, we use the index + # as part of the key. + for idx, volume_mount in enumerate(extra_volume_mounts): + volume_mounts.update({f"{idx}-{volume_mount['name']}": volume_mount}) + +c.KubeSpawner.volumes = volumes +c.KubeSpawner.volume_mounts = volume_mounts c.JupyterHub.services = [] c.JupyterHub.load_roles = [] diff --git a/jupyterhub/values.schema.yaml b/jupyterhub/values.schema.yaml index 9721ef84ea..32ad40eb5a 100644 --- a/jupyterhub/values.schema.yaml +++ b/jupyterhub/values.schema.yaml @@ -2418,8 +2418,29 @@ properties: Configures `KubeSpawner.storage_extra_labels`. Note that these labels are set on the PVC during creation only and won't be updated after creation. - extraVolumeMounts: *extraVolumeMounts-spec - extraVolumes: *extraVolumes-spec + extraVolumeMounts: + type: [object, array] + description: | + Injects extra volume mounts into `KubeSpawner.volume_mounts` dictionary. + Can be a dictionary or an array. + If it's an array, each item must be a volume mount configuration in k8s + native syntax. A combination of the volume name and its index is used as the key + in `KubeSpawner.volume_mounts` dictionary and the value is the volume mount + configuration. + If `extraVolumeMounts` is defined as a dictionary, the keys of the dictionary + can be any descriptive name for the volume mount and the value is the volume mount + configuration in k8s native syntax. + extraVolumes: + type: [object, array] + description: | + Injects extra volumes into `KubeSpawner.volumes` dictionary. Can be a dictionary + or an array. + If it's an array, each item must be volume configuration in k8s native + syntax. The name of the volume is used as the key in `KubeSpawner.volumes` + dictionary while the value is the volume configuration. + If `extraVolumes` is defined as a dictionary, the keys of the dictionary + can be any descriptive name for the volume and the value must be the volume + configuration in k8s native syntax. homeMountPath: type: string description: | diff --git a/jupyterhub/values.yaml b/jupyterhub/values.yaml index e4df1339b9..d4b5c559ab 100644 --- a/jupyterhub/values.yaml +++ b/jupyterhub/values.yaml @@ -404,8 +404,8 @@ singleuser: storage: type: dynamic extraLabels: {} - extraVolumes: [] - extraVolumeMounts: [] + extraVolumes: {} + extraVolumeMounts: {} static: pvcName: subPath: "{username}" diff --git a/tests/test_spawn.py b/tests/test_spawn.py index 86cd325da1..cc05af88f4 100644 --- a/tests/test_spawn.py +++ b/tests/test_spawn.py @@ -129,6 +129,22 @@ def test_spawn_basic( assert ( c.returncode == 0 ), "The singleuser.extraFiles configuration doesn't seem to have been honored!" + + # check the extra volume and volume mount exists + c = subprocess.run( + [ + "kubectl", + "exec", + pod_name, + "--", + "sh", + "-c", + "if [ ! -d /test-volume ]; then exit 1; fi", + ] + ) + assert ( + c.returncode == 0 + ), "The singleuser.storage.extraVolumes and extraVolumeMounts configuration doesn't seem to have been honored!" finally: _delete_server(api_request, jupyter_user, request_data["test_timeout"]) diff --git a/tools/templates/lint-and-validate-values.yaml b/tools/templates/lint-and-validate-values.yaml index 80f45b470d..0e9f85ae05 100644 --- a/tools/templates/lint-and-validate-values.yaml +++ b/tools/templates/lint-and-validate-values.yaml @@ -455,8 +455,8 @@ singleuser: storage: type: dynamic extraLabels: *labels - extraVolumes: [] - extraVolumeMounts: [] + extraVolumes: {} + extraVolumeMounts: {} static: pvcName: subPath: "{username}"