From b31024a0a30ab68e0130408cae30203c95b134fe Mon Sep 17 00:00:00 2001 From: Angus Hollands Date: Fri, 15 May 2026 12:59:34 +0100 Subject: [PATCH] feat: split out custom config --- helm-charts/basehub/config/01-custom-theme.py | 8 + .../basehub/config/02-basehub-spawner.py | 242 +++++++ .../basehub/config/03-2i2c-staff-access.py | 30 + .../basehub/config/04-per-user-disk.py | 57 ++ .../basehub/config/05-profile-groups.py | 134 ++++ .../basehub/config/06-salted-username.py | 73 ++ .../config/07-enable-fancy-profiles.py | 3 + .../basehub/config/08-auth-state-groups.py | 23 + .../config/10-skip-refresh-test-user.py | 11 + .../11-dask-hub-add-dask-gateway-values.py | 73 ++ .../templates/configmap-hub-config.yaml | 8 + helm-charts/basehub/values.yaml | 645 +----------------- 12 files changed, 696 insertions(+), 611 deletions(-) create mode 100644 helm-charts/basehub/config/01-custom-theme.py create mode 100644 helm-charts/basehub/config/02-basehub-spawner.py create mode 100644 helm-charts/basehub/config/03-2i2c-staff-access.py create mode 100644 helm-charts/basehub/config/04-per-user-disk.py create mode 100644 helm-charts/basehub/config/05-profile-groups.py create mode 100644 helm-charts/basehub/config/06-salted-username.py create mode 100644 helm-charts/basehub/config/07-enable-fancy-profiles.py create mode 100644 helm-charts/basehub/config/08-auth-state-groups.py create mode 100644 helm-charts/basehub/config/10-skip-refresh-test-user.py create mode 100644 helm-charts/basehub/config/11-dask-hub-add-dask-gateway-values.py create mode 100644 helm-charts/basehub/templates/configmap-hub-config.yaml diff --git a/helm-charts/basehub/config/01-custom-theme.py b/helm-charts/basehub/config/01-custom-theme.py new file mode 100644 index 0000000000..cbdd024ff1 --- /dev/null +++ b/helm-charts/basehub/config/01-custom-theme.py @@ -0,0 +1,8 @@ +# adds a JupyterHub template path and updates template variables + +from z2jh import get_config + +c.JupyterHub.template_paths.insert(0, "/usr/local/share/jupyterhub/custom_templates") +c.JupyterHub.template_vars.update( + {"custom": get_config("custom.homepage.templateVars")} +) diff --git a/helm-charts/basehub/config/02-basehub-spawner.py b/helm-charts/basehub/config/02-basehub-spawner.py new file mode 100644 index 0000000000..dd7b438614 --- /dev/null +++ b/helm-charts/basehub/config/02-basehub-spawner.py @@ -0,0 +1,242 @@ +""" +Helpers for creating BinderSpawners + +FIXME: +This file is defined in binderhub/binderspawner_mixin.py +and is copied to helm-chart/binderhub/values.yaml +by ci/check_embedded_chart_code.py + +The BinderHub repo is just used as the distribution mechanism for this spawner, +BinderHub itself doesn't require this code. + +Longer term options include: +- Move BinderSpawnerMixin to a separate Python package and include it in the Z2JH Hub + image +- Override the Z2JH hub with a custom image built in this repository +- Duplicate the code here and in binderhub/binderspawner_mixin.py +""" + +# Updates JupyterHub.spawner_class and KubeSpawner.modify_pod_hook to +# handle features introduced by the basehub chart, specifically those +# configured via: +# +# jupyterhub.custom.singleuserAdmin +# +import shlex + +from kubernetes_asyncio.client.models import V1Container, V1VolumeMount +from kubespawner import KubeSpawner +from kubespawner.utils import get_k8s_model +from tornado import web +from traitlets import Bool, Unicode +from traitlets.config import Configurable +from z2jh import get_config + +# This is copy-pasted exactly from https://github.com/jupyterhub/binderhub/blob/c6c5dc8fe73f81ca538c47b420b33f317c3aa8ae/helm-chart/binderhub/values.yaml#L87 +# Should be updated every time the upstream code changes + + +class BinderSpawnerMixin(Configurable): + """ + Mixin to convert a JupyterHub container spawner to a BinderHub spawner + + Container spawner must support the following properties that will be set + via spawn options: + - image: Container image to launch + - token: JupyterHub API token + """ + + def __init__(self, *args, **kwargs): + # Is this right? Is it possible to having multiple inheritance with both + # classes using traitlets? + # https://stackoverflow.com/questions/9575409/calling-parent-class-init-with-multiple-inheritance-whats-the-right-way + # https://github.com/ipython/traitlets/pull/175 + super().__init__(*args, **kwargs) + + auth_enabled = Bool( + False, + help=""" + Enable authenticated binderhub setup. + + Requires `jupyterhub-singleuser` to be available inside the repositories + being built. + """, + config=True, + ) + + cors_allow_origin = Unicode( + "", + help=""" + Origins that can access the spawned notebooks. + + Sets the Access-Control-Allow-Origin header in the spawned + notebooks. Set to '*' to allow any origin to access spawned + notebook servers. + + See also BinderHub.cors_allow_origin in binderhub config + for controlling CORS policy for the BinderHub API endpoint. + """, + config=True, + ) + + def get_args(self): + if self.auth_enabled: + args = super().get_args() + else: + args = [ + "--ip=0.0.0.0", + f"--port={self.port}", + f"--NotebookApp.base_url={self.server.base_url}", + f"--NotebookApp.token={self.user_options['token']}", + "--NotebookApp.trust_xheaders=True", + ] + if self.default_url: + args.append(f"--NotebookApp.default_url={self.default_url}") + + if self.cors_allow_origin: + args.append("--NotebookApp.allow_origin=" + self.cors_allow_origin) + # allow_origin=* doesn't properly allow cross-origin requests to single files + # see https://github.com/jupyter/notebook/pull/5898 + if self.cors_allow_origin == "*": + args.append("--NotebookApp.allow_origin_pat=.*") + args += self.args + # ServerApp compatibility: duplicate NotebookApp args + for arg in list(args): + if arg.startswith("--NotebookApp."): + args.append(arg.replace("--NotebookApp.", "--ServerApp.")) + return args + + def start(self): + if not self.auth_enabled: + if "token" not in self.user_options: + raise web.HTTPError(400, "token required") + if "image" not in self.user_options: + raise web.HTTPError(400, "image required") + if "image" in self.user_options: + self.image = self.user_options["image"] + return super().start() + + def get_env(self): + env = super().get_env() + if "repo_url" in self.user_options: + env["BINDER_REPO_URL"] = self.user_options["repo_url"] + for key in ( + "binder_ref_url", + "binder_launch_host", + "binder_persistent_request", + "binder_request", + ): + if key in self.user_options: + env[key.upper()] = self.user_options[key] + return env + + +spawner_base_classes = [KubeSpawner] +if get_config("custom.binderhubUI.enabled"): + spawner_base_classes = [BinderSpawnerMixin, KubeSpawner] + + # Set start timeout to 15minutes + # This isn't ideal - they should start sooner than that! But + # we recognize that sometimes pulling in a lot of images takes + # a while, especially with node spinup. So we increase the timeout + # to reduce our rate of false positives + c.Spawner.start_timeout = 15 * 60 + + +class BaseHubSpawner(*spawner_base_classes): + def start(self, *args, **kwargs): + """ + Modify admin users' spawners' non-list config based on + `jupyterhub.custom.singleuserAdmin`. + + The list config is handled separately in by the + `modify_pod_hook`. + """ + custom_admin = get_config("custom.singleuserAdmin", {}) + if not (self.user.admin and custom_admin): + return super().start(*args, **kwargs) + + admin_environment = custom_admin.get("extraEnv", {}) + self.environment.update(admin_environment) + + admin_service_account = custom_admin.get("serviceAccountName") + if admin_service_account: + self.service_account = admin_service_account + + return super().start(*args, **kwargs) + + +c.JupyterHub.spawner_class = BaseHubSpawner + + +def modify_pod_hook(spawner, pod): + """ + Modify admin user's pod manifests based on *dict* config under + `jupyterhub.custom.singleuserAdmin`. + + This hook is required to ensures that list config under + `jupyterhub.custom.singleuserAdmin` are appended and not just + overridden when a profile_list entry has a kubespawner_override + modifying the same config. + """ + # This if-statement is a patch to ensure that if there are no + # initContainers, we can at least work with an empty list, so that + # later appending actions do not fail. + if pod.spec.init_containers is None: + pod.spec.init_containers = [] + + custom_admin = get_config("custom.singleuserAdmin", {}) + if spawner.user.admin and custom_admin: + # Setup admin mounts only for admins + for c in pod.spec.containers: + if c.name == "notebook": + notebook_container = c + break + else: + raise Exception("No container named 'notebook' found in pod definition") + + admin_volume_mounts = custom_admin.get("extraVolumeMounts", {}) + # custom.singleuserAdmin.extraVolumeMounts is a dict now + admin_volume_mounts = list(admin_volume_mounts.values()) + notebook_container.volume_mounts += [ + get_k8s_model(V1VolumeMount, obj) for obj in (admin_volume_mounts) + ] + + # Setup iptables blocking for everyone + block_ports = [2049, 20048, 111] + commands = [] + for protocol in ("tcp", "udp"): + for port in block_ports: + commands.append( + [ + "iptables", + "--append", + "OUTPUT", + "--protocol", + protocol, + "--destination-port", + str(port), + "--jump", + "DROP", + ] + ) + + shell_command = " && ".join([shlex.join(c) for c in commands]) + + iptables_container = { + "name": "block-nfs-access", + "image": "quay.io/jupyterhub/k8s-network-tools:4.1.0", + "securityContext": { + "runAsUser": 0, + "privileged": True, + "capabilities": {"add": ["NET_ADMIN"]}, + }, + "command": ["/bin/sh", "-c", shell_command], + } + + pod.spec.init_containers.append(get_k8s_model(V1Container, iptables_container)) + + return pod + + +c.KubeSpawner.modify_pod_hook = modify_pod_hook diff --git a/helm-charts/basehub/config/03-2i2c-staff-access.py b/helm-charts/basehub/config/03-2i2c-staff-access.py new file mode 100644 index 0000000000..13e4cdc171 --- /dev/null +++ b/helm-charts/basehub/config/03-2i2c-staff-access.py @@ -0,0 +1,30 @@ +from z2jh import get_config + +add_staff_user_ids_to_admin_users = get_config( + "custom.2i2c.add_staff_user_ids_to_admin_users", False +) + +if add_staff_user_ids_to_admin_users: + user_id_type = get_config("custom.2i2c.add_staff_user_ids_of_type") + staff_user_ids = get_config(f"custom.2i2c.staff_{user_id_type}_ids", []) + # `c.Authenticator.admin_users` can contain additional admins, can be an empty list, + # or it cannot be defined at all. + # This should cover all these cases. + staff_user_ids.extend(get_config("hub.config.Authenticator.admin_users", [])) + c.Authenticator.admin_users = staff_user_ids + +# Appends 2i2c staff access by GitHub team membership by default for GitHub authenticated hubs. +if ( + c.JupyterHub.authenticator_class == "github" + and type(c.GitHubOAuthenticator.allowed_organizations) == list +): + c.GitHubOAuthenticator.allowed_organizations.append( + "2i2c-org:hub-access-for-2i2c-staff" + ) +elif c.JupyterHub.authenticator_class == "github": + print( + "No GitHubOAuthenticator.allowed_organizations found, setting to ['2i2c-org:hub-access-for-2i2c-staff']" + ) + c.GitHubOAuthenticator.allowed_organizations = [ + "2i2c-org:hub-access-for-2i2c-staff" + ] diff --git a/helm-charts/basehub/config/04-per-user-disk.py b/helm-charts/basehub/config/04-per-user-disk.py new file mode 100644 index 0000000000..f233d0c5c7 --- /dev/null +++ b/helm-charts/basehub/config/04-per-user-disk.py @@ -0,0 +1,57 @@ +# Optionally, create a PVC per user - useful for per-user databases +from functools import partial + +from jupyterhub.utils import exponential_backoff +from kubespawner.objects import make_pvc +from z2jh import get_config + + +def make_extra_pvc(component, name_template, storage_class, storage_capacity, spawner): + """ + Create a PVC object with given spec + """ + labels = spawner._build_common_labels({}) + labels.update({"component": component}) + annotations = spawner._build_common_annotations({}) + storage_selector = spawner._expand_all(spawner.storage_selector) + return make_pvc( + name=spawner._expand_all(name_template), + storage_class=storage_class, + access_modes=["ReadWriteOnce"], + selector={}, + storage=storage_capacity, + labels=labels, + annotations=annotations, + ) + + +extra_user_pvcs = get_config("custom.singleuser.extraPVCs", {}) +if extra_user_pvcs: + make_db_pvc = partial( + make_extra_pvc, "db-storage", "db-{username}", "standard", "1G" + ) + + pvc_makers = [ + partial(make_extra_pvc, "extra-pvc", p["name"], p["class"], p["capacity"]) + for p in extra_user_pvcs + ] + + async def ensure_db_pvc(spawner): + """ " + Ensure a PVC is created for this user's database volume + """ + for pvc_maker in pvc_makers: + pvc = pvc_maker(spawner) + # If there's a timeout, just let it propagate + await exponential_backoff( + partial( + spawner._make_create_pvc_request, + pvc, + spawner.k8s_api_request_timeout, + ), + f"Could not create pvc {pvc.metadata.name}", + # Each req should be given k8s_api_request_timeout seconds. + timeout=spawner.k8s_api_request_retry_timeout, + ) + + c.Spawner.pre_spawn_hook = ensure_db_pvc diff --git a/helm-charts/basehub/config/05-profile-groups.py b/helm-charts/basehub/config/05-profile-groups.py new file mode 100644 index 0000000000..e3fd2bea12 --- /dev/null +++ b/helm-charts/basehub/config/05-profile-groups.py @@ -0,0 +1,134 @@ +# Re-assignes c.KubeSpawner.profile_list to a callable that filters the +# initial configuration of profile_list based on the user's github +# org/team membership as declared via "allowed_groups" read from +# profile_list profiles. +# +# This only has effect if: +# +# - GitHubOAuthenticator is used. +# - GitHubOAuthenticator.populate_teams_in_auth_state is True, that +# requires Authenticator.enable_auth_state to be True as well. +# - The user is a normal user, and not "deployment-service-check". +# +from copy import deepcopy +from functools import partial +from textwrap import dedent + +from oauthenticator.github import GitHubOAuthenticator +from tornado import web + + +async def profile_list_allowed_groups_filter(original_profile_list, spawner): + """ + Returns the initially configured profile_list filtered based on the + user's membership in each profile's `allowed_groups`. If + `allowed_groups` isn't set for a profile, that profile is allowed for + everyone. Similar functionality is provided for both `unlisted_choice` and + `choice` inside `profile_options`. + + `allowed_groups` is a list of JupyterHub groups, set up by the authenticator. + In addition, for use with GitHubOAuthenticator, it can be a list of + teams the user is a part of, of form ':'. + + If the returned profile_list is filtered to not include any profiles, + an error is raised and the user isn't allowed to start a server. + """ + if spawner.user.name == "deployment-service-check": + print("Ignoring allowed_groups check for deployment-service-check") + return original_profile_list + + # casefold group names so we can do case insensitive comparisons. + groups = {g.name.casefold() for g in spawner.user.groups} + + # If we're using GitHubOAuthenticator, add the user's teams to the groups as well. + # Eventually this can be removed, as the user's teams can be set to be groups + # once https://github.com/jupyterhub/oauthenticator/pull/735 is merged + if isinstance(spawner.authenticator, GitHubOAuthenticator): + # Ensure auth_state is populated with teams info + auth_state = await spawner.user.get_auth_state() + if not auth_state or "teams" not in auth_state: + print( + f"User {spawner.user.name} does not have any auth_state set, profile_list filtering not available" + ) + + else: + # casefold teams to match what GitHub's API does when doing authorization calls + groups |= { + f"{team['organization']['login']}:{team['slug']}".casefold() + for team in auth_state["teams"] + } + + print(f"User {spawner.user.name} is part of groups {' '.join(groups)}") + + # Filter out profiles with allowed_groups set if the user isn't part of the group + allowed_profiles = [] + for original_profile in original_profile_list: + # Make a copy, as we'll be modifying this profile + profile = deepcopy(original_profile) + + # Handle `allowed_groups` specified in profile_options + if "profile_options" in profile: + for k, po in profile["profile_options"].items(): + # If `unlisted_choice` has an `allowed_groups` and the current + # user is not present in any of those teams, we delete the + # `unlisted_choice` config entirely for this option. The user + # will then not be allowed to 'write in' a value. + if "unlisted_choice" in po: + if "allowed_groups" in po["unlisted_choice"]: + if not (set(po["unlisted_choice"]["allowed_groups"]) & groups): + del po["unlisted_choice"] + + if "choices" in po: + new_choices = {} + for k, c in po["choices"].items(): + # If `allowed_groups` is not set for a profile option, it is automatically + # allowed for everyone + if "allowed_groups" not in c: + new_choices[k] = c + # If `allowed_groups` *is* set for a profile option, it is allowed only for + # members of that team. + else: + allowed_groups = {g.casefold() for g in c["allowed_groups"]} + if allowed_groups & groups: + new_choices[k] = c + po["choices"] = new_choices + + if "allowed_groups" not in profile: + allowed_profiles.append(profile) + else: + allowed_groups = {g.casefold() for g in profile.get("allowed_groups", [])} + + if allowed_groups & groups: + print( + f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on team membership" + ) + allowed_profiles.append(profile) + continue + + if len(allowed_profiles) == 0: + # If no profiles are allowed, user should not be able to spawn anything! + # If we don't explicitly stop this, user will be logged into the 'default' settings + # set in singleuser, without any profile overrides. Not desired behavior + # FIXME: User doesn't actually see this error message, just the generic 403. + error_msg = dedent(f""" + Your JupyterHub group membership is insufficient to launch any server profiles. + + JupyterHub groups you are a member of are {", ".join(groups)}. + + If you are part of additional groups, log out of this JupyterHub and log back in to refresh that information. + """) + raise web.HTTPError(403, error_msg) + + return allowed_profiles + + +# Only set our custom filter if +# profile_list is specified (otherwise users will get an empty screen when trying to launch servers) +if c.KubeSpawner.profile_list: + # Customize list of profiles dynamically, rather than override options form. + # This is more secure, as users can't override the options available to them via the hub API + # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable + # capture related issues. + c.KubeSpawner.profile_list = partial( + profile_list_allowed_groups_filter, deepcopy(c.KubeSpawner.profile_list) + ) diff --git a/helm-charts/basehub/config/06-salted-username.py b/helm-charts/basehub/config/06-salted-username.py new file mode 100644 index 0000000000..fe645a769b --- /dev/null +++ b/helm-charts/basehub/config/06-salted-username.py @@ -0,0 +1,73 @@ +# Allow anonymizing username to not store *any* PII +import base64 +import hashlib +import json +import os + +from z2jh import get_config + + +def salt_username(authenticator, handler, auth_model): + # Combine parts of user info with different provenances to eliminate + # possible deanonym attacks when things get leaked. + + # FIXME: Provide useful error message when using an auth provider that + # doesn't give us 'oidc' + # FIXME: Raise error if this is attempted to be used with anything other than CILogon + USERNAME_DERIVATION_PEPPER = bytes.fromhex(os.environ["USERNAME_DERIVATION_PEPPER"]) + cilogon_user = auth_model["auth_state"]["cilogon_user"] + user_key_parts = { + # Opaque ID from CILogon + "sub": cilogon_user["sub"], + # Combined together, opaque ID from upstream IDP (GitHub, Google, etc) + "idp": cilogon_user["idp"], + "oidc": cilogon_user["oidc"], + } + + # Use JSON here, so we don't have to deal with picking a string + # delimiter that will not appear in any of the parts. + # keys are sorted to ensure stable output over time + user_key = json.dumps(user_key_parts, sort_keys=True).encode("utf-8") + + # The cryptographic choices made here are: + # - Use blake2, because it's fairly modern + # - Set blake2 to output 32 bytes as output, which is good enough for our use case + # - Use base32 encoding, as it will produce maximum of 56 characters + # for 32 bytes output by blake2. We have 63 character username + # limits in many parts of our code (particularly, in usernames + # being part of labels in kubernetes pods), so this helps + # - Convert everything to lowercase, as base64.b32encode produces + # all uppercase characters by default. Our usernames are preferably + # lowercase, as uppercase characters must be encoded for kubernetes' + # sake + # - strip the = padding provided by base64.b32encode. This is present + # primarily to be able to determine length of the original byte + # sequence accurately. We don't care about that here. Also = is + # encoded in kubernetes and puts us over the 63 char limit. + # - Use blake2 here explicitly as a keyed hash, rather than use + # hmac. This is the canonical way to do this, and helps make it + # clearer that we want it to output 32byte hashes. We could have + # used a 16byte hash here for shorter usernames, but it is unclear + # what that does to the security properties. So better safe than + # sorry, and stick to 32bytes (rather than the default 64) + digested_user_key = ( + base64.b32encode( + hashlib.blake2b( + user_key, key=USERNAME_DERIVATION_PEPPER, digest_size=32 + ).digest() + ) + .decode("utf-8") + .lower() + .rstrip("=") + ) + + # Replace the default name with our digested name, thus + # discarding the default name + auth_model["name"] = digested_user_key + + return auth_model + + +if get_config("custom.auth.anonymizeUsername", False): + # https://jupyterhub.readthedocs.io/en/stable/reference/api/auth.html#jupyterhub.auth.Authenticator.post_auth_hook + c.Authenticator.post_auth_hook = salt_username diff --git a/helm-charts/basehub/config/07-enable-fancy-profiles.py b/helm-charts/basehub/config/07-enable-fancy-profiles.py new file mode 100644 index 0000000000..d6d7cf9458 --- /dev/null +++ b/helm-charts/basehub/config/07-enable-fancy-profiles.py @@ -0,0 +1,3 @@ +from jupyterhub_fancy_profiles import setup_ui + +setup_ui(c) diff --git a/helm-charts/basehub/config/08-auth-state-groups.py b/helm-charts/basehub/config/08-auth-state-groups.py new file mode 100644 index 0000000000..7e5760c08b --- /dev/null +++ b/helm-charts/basehub/config/08-auth-state-groups.py @@ -0,0 +1,23 @@ +# Pass the user's GitHub teams from auth_state to JupyterHub group memberships. + + +async def custom_auth_state_groups_key(auth_state): + if "teams" not in auth_state.keys(): + print("No GitHub teams found in auth_state.") + return None + else: + groups_list = [] + for team in auth_state["teams"]: + if ( + f"{team['organization']['login']}:{team['slug']}" + not in c.GitHubOAuthenticator.allowed_organizations + ): + continue + else: + groups_list.append(f"{team['organization']['login']}:{team['slug']}") + custom_auth_state_groups_key.groups_list = groups_list + return groups_list + + +if c.JupyterHub.authenticator_class == "github": + c.GitHubOAuthenticator.auth_state_groups_key = custom_auth_state_groups_key diff --git a/helm-charts/basehub/config/10-skip-refresh-test-user.py b/helm-charts/basehub/config/10-skip-refresh-test-user.py new file mode 100644 index 0000000000..578123a8c4 --- /dev/null +++ b/helm-charts/basehub/config/10-skip-refresh-test-user.py @@ -0,0 +1,11 @@ +def refresh_user_hook(authenticator, user, auth_state): + if user.name == "deployment-service-check": + # if this is the user, + # refresh_user doesn't make sense + # consider it always fresh + return True + # for all other users, refresh as usual + return None + + +c.OAuthenticator.refresh_user_hook = refresh_user_hook diff --git a/helm-charts/basehub/config/11-dask-hub-add-dask-gateway-values.py b/helm-charts/basehub/config/11-dask-hub-add-dask-gateway-values.py new file mode 100644 index 0000000000..d6e189f868 --- /dev/null +++ b/helm-charts/basehub/config/11-dask-hub-add-dask-gateway-values.py @@ -0,0 +1,73 @@ +# Initially copied from https://github.com/dask/helm-chart/blob/main/daskhub/values.yaml +# 1. Sets `DASK_GATEWAY__PROXY_ADDRESS` in the singleuser environment. +# 2. Adds the URL for the Dask Gateway JupyterHub service. +import os + +from z2jh import get_config + +if get_config("custom.daskhubSetup.enabled"): + # Default all users on hubs with dask-gateway to use JupyterLab + c.Spawner.default_url = "/lab" + + # Add an extra label that allows user pods to talk to the proxy pod + # in clusters with networkPolicy enabled so kernels can talk to the + # dask-gateway service via the proxy + c.KubeSpawner.extra_labels.update( + {"hub.jupyter.org/network-access-proxy-http": "true"} + ) + # These are set by jupyterhub. + release_name = os.environ["HELM_RELEASE_NAME"] + release_namespace = os.environ["POD_NAMESPACE"] + if "PROXY_HTTP_SERVICE_HOST" in os.environ: + # https is enabled, we want to use the internal http service. + gateway_address = "http://{}:{}/services/dask-gateway/".format( + os.environ["PROXY_HTTP_SERVICE_HOST"], + os.environ["PROXY_HTTP_SERVICE_PORT"], + ) + print(f"Setting DASK_GATEWAY__ADDRESS {gateway_address} from HTTP service") + else: + gateway_address = "http://proxy-public/services/dask-gateway" + print(f"Setting DASK_GATEWAY__ADDRESS {gateway_address}") + # Internal address to connect to the Dask Gateway. + c.KubeSpawner.environment.setdefault("DASK_GATEWAY__ADDRESS", gateway_address) + # Internal address for the Dask Gateway proxy. + c.KubeSpawner.environment.setdefault( + "DASK_GATEWAY__PROXY_ADDRESS", + "gateway://traefik-{}-dask-gateway.{}:80".format( + release_name, release_namespace + ), + ) + # Relative address for the dashboard link. + c.KubeSpawner.environment.setdefault( + "DASK_GATEWAY__PUBLIC_ADDRESS", "/services/dask-gateway/" + ) + # Use JupyterHub to authenticate with Dask Gateway. + c.KubeSpawner.environment.setdefault("DASK_GATEWAY__AUTH__TYPE", "jupyterhub") + + # Add some settings for dask gateway via environment variables + # https://docs.dask.org/en/latest/configuration.html has more information + # Kubernetes env variable expansion via `{{}}` is used here. See + # https://kubernetes.io/docs/tasks/inject-data-application/define-interdependent-environment-variables/ + # for more information + c.KubeSpawner.environment.update( + { + # Specify what image dask-gateway workers and schedulers should use + "DASK_GATEWAY__CLUSTER__OPTIONS__IMAGE": "{{JUPYTER_IMAGE_SPEC}}", + "DASK_GATEWAY__CLUSTER__OPTIONS__ENVIRONMENT": '{{"SCRATCH_BUCKET": "$(SCRATCH_BUCKET)", "PANGEO_SCRATCH": "$(PANGEO_SCRATCH)"}}', + "DASK_DISTRIBUTED__DASHBOARD__LINK": "{{JUPYTERHUB_SERVICE_PREFIX}}proxy/{{port}}/status", + } + ) + + # Adds Dask Gateway as a JupyterHub service to make the gateway available at + # {HUB_URL}/services/dask-gateway + service_url = "http://traefik-{}-dask-gateway.{}".format( + release_name, release_namespace + ) + for service in c.JupyterHub.services: + if service["name"] == "dask-gateway": + if not service.get("url", None): + print("Adding dask-gateway service URL") + service.setdefault("url", service_url) + break + else: + print("dask-gateway service not found, this should not happen!") diff --git a/helm-charts/basehub/templates/configmap-hub-config.yaml b/helm-charts/basehub/templates/configmap-hub-config.yaml new file mode 100644 index 0000000000..ae2a1ead98 --- /dev/null +++ b/helm-charts/basehub/templates/configmap-hub-config.yaml @@ -0,0 +1,8 @@ +kind: ConfigMap +apiVersion: v1 +metadata: + name: hub-custom-config-files + labels: + app: jupyterhub +data: + {{- (.Files.Glob "config/*").AsConfig | nindent 2 }} diff --git a/helm-charts/basehub/values.yaml b/helm-charts/basehub/values.yaml index f1ab23db48..586bb00137 100644 --- a/helm-charts/basehub/values.yaml +++ b/helm-charts/basehub/values.yaml @@ -1006,6 +1006,9 @@ jupyterhub: extraVolumes: - name: custom-templates emptyDir: {} + - name: custom-config + configMap: + name: hub-custom-config-files extraVolumeMounts: - mountPath: /usr/local/share/jupyterhub/custom_templates name: custom-templates @@ -1013,6 +1016,37 @@ jupyterhub: - mountPath: /usr/local/share/jupyterhub/static/extra-assets name: custom-templates subPath: repo/extra-assets + # 2i2c Hub config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/01-custom-theme.py + subPath: 01-custom-theme.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/02-basehub-spawner.py + subPath: 02-basehub-spawner.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/03-2i2c-staff-access.py + subPath: 03-2i2c-staff-access.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/04-per-user-disk.py + subPath: 04-per-user-disk.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/05-profile-groups.py + subPath: 05-profile-groups.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/06-salted-username.py + subPath: 06-salted-username.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/07-enable-fancy-profiles.py + subPath: 07-enable-fancy-profiles.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/08-auth-state-groups.py + subPath: 08-auth-state-groups.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/10-skip-refresh-test-user.py + subPath: 10-skip-refresh-test-user.py + name: custom-config + - mountPath: /usr/local/etc/jupyterhub/jupyterhub_config.d/11-dask-hub-add-dask-gateway-values.py + subPath: 11-dask-hub-add-dask-gateway-values.py + name: custom-config services: # hub-health service helps us run health checks from the deployer script. # The JupyterHub Helm chart will automatically generate an API token for @@ -1075,617 +1109,6 @@ jupyterhub: memory: 128Mi limits: memory: 2Gi - extraConfig: - # This is copy-pasted exactly from https://github.com/jupyterhub/binderhub/blob/c6c5dc8fe73f81ca538c47b420b33f317c3aa8ae/helm-chart/binderhub/values.yaml#L87 - # Should be updated every time the upstream code changes - 0-binderspawnermixin: | - """ - Helpers for creating BinderSpawners - - FIXME: - This file is defined in binderhub/binderspawner_mixin.py - and is copied to helm-chart/binderhub/values.yaml - by ci/check_embedded_chart_code.py - - The BinderHub repo is just used as the distribution mechanism for this spawner, - BinderHub itself doesn't require this code. - - Longer term options include: - - Move BinderSpawnerMixin to a separate Python package and include it in the Z2JH Hub - image - - Override the Z2JH hub with a custom image built in this repository - - Duplicate the code here and in binderhub/binderspawner_mixin.py - """ - - from tornado import web - from traitlets import Bool, Unicode - from traitlets.config import Configurable - - - class BinderSpawnerMixin(Configurable): - """ - Mixin to convert a JupyterHub container spawner to a BinderHub spawner - - Container spawner must support the following properties that will be set - via spawn options: - - image: Container image to launch - - token: JupyterHub API token - """ - - def __init__(self, *args, **kwargs): - # Is this right? Is it possible to having multiple inheritance with both - # classes using traitlets? - # https://stackoverflow.com/questions/9575409/calling-parent-class-init-with-multiple-inheritance-whats-the-right-way - # https://github.com/ipython/traitlets/pull/175 - super().__init__(*args, **kwargs) - - auth_enabled = Bool( - False, - help=""" - Enable authenticated binderhub setup. - - Requires `jupyterhub-singleuser` to be available inside the repositories - being built. - """, - config=True, - ) - - cors_allow_origin = Unicode( - "", - help=""" - Origins that can access the spawned notebooks. - - Sets the Access-Control-Allow-Origin header in the spawned - notebooks. Set to '*' to allow any origin to access spawned - notebook servers. - - See also BinderHub.cors_allow_origin in binderhub config - for controlling CORS policy for the BinderHub API endpoint. - """, - config=True, - ) - - def get_args(self): - if self.auth_enabled: - args = super().get_args() - else: - args = [ - "--ip=0.0.0.0", - f"--port={self.port}", - f"--NotebookApp.base_url={self.server.base_url}", - f"--NotebookApp.token={self.user_options['token']}", - "--NotebookApp.trust_xheaders=True", - ] - if self.default_url: - args.append(f"--NotebookApp.default_url={self.default_url}") - - if self.cors_allow_origin: - args.append("--NotebookApp.allow_origin=" + self.cors_allow_origin) - # allow_origin=* doesn't properly allow cross-origin requests to single files - # see https://github.com/jupyter/notebook/pull/5898 - if self.cors_allow_origin == "*": - args.append("--NotebookApp.allow_origin_pat=.*") - args += self.args - # ServerApp compatibility: duplicate NotebookApp args - for arg in list(args): - if arg.startswith("--NotebookApp."): - args.append(arg.replace("--NotebookApp.", "--ServerApp.")) - return args - - def start(self): - if not self.auth_enabled: - if "token" not in self.user_options: - raise web.HTTPError(400, "token required") - if "image" not in self.user_options: - raise web.HTTPError(400, "image required") - if "image" in self.user_options: - self.image = self.user_options["image"] - return super().start() - - def get_env(self): - env = super().get_env() - if "repo_url" in self.user_options: - env["BINDER_REPO_URL"] = self.user_options["repo_url"] - for key in ( - "binder_ref_url", - "binder_launch_host", - "binder_persistent_request", - "binder_request", - ): - if key in self.user_options: - env[key.upper()] = self.user_options[key] - return env - 01-custom-theme: | - # adds a JupyterHub template path and updates template variables - - from z2jh import get_config - c.JupyterHub.template_paths.insert(0,'/usr/local/share/jupyterhub/custom_templates') - c.JupyterHub.template_vars.update({ - 'custom': get_config('custom.homepage.templateVars') - }) - 02-basehub-spawner: | - # Updates JupyterHub.spawner_class and KubeSpawner.modify_pod_hook to - # handle features introduced by the basehub chart, specifically those - # configured via: - # - # jupyterhub.custom.singleuserAdmin - # - from kubernetes_asyncio.client.models import V1VolumeMount, V1Container - from kubespawner import KubeSpawner - from kubespawner.utils import get_k8s_model - from z2jh import get_config - import shlex - - spawner_base_classes = [KubeSpawner] - if get_config("custom.binderhubUI.enabled"): - spawner_base_classes = [BinderSpawnerMixin, KubeSpawner] - - # Set start timeout to 15minutes - # This isn't ideal - they should start sooner than that! But - # we recognize that sometimes pulling in a lot of images takes - # a while, especially with node spinup. So we increase the timeout - # to reduce our rate of false positives - c.Spawner.start_timeout = 15 * 60 - - class BaseHubSpawner(*spawner_base_classes): - def start(self, *args, **kwargs): - """ - Modify admin users' spawners' non-list config based on - `jupyterhub.custom.singleuserAdmin`. - - The list config is handled separately in by the - `modify_pod_hook`. - """ - custom_admin = get_config('custom.singleuserAdmin', {}) - if not (self.user.admin and custom_admin): - return super().start(*args, **kwargs) - - admin_environment = custom_admin.get('extraEnv', {}) - self.environment.update(admin_environment) - - admin_service_account = custom_admin.get('serviceAccountName') - if admin_service_account: - self.service_account = admin_service_account - - return super().start(*args, **kwargs) - - c.JupyterHub.spawner_class = BaseHubSpawner - - - def modify_pod_hook(spawner, pod): - """ - Modify admin user's pod manifests based on *dict* config under - `jupyterhub.custom.singleuserAdmin`. - - This hook is required to ensures that list config under - `jupyterhub.custom.singleuserAdmin` are appended and not just - overridden when a profile_list entry has a kubespawner_override - modifying the same config. - """ - # This if-statement is a patch to ensure that if there are no - # initContainers, we can at least work with an empty list, so that - # later appending actions do not fail. - if pod.spec.init_containers is None: - pod.spec.init_containers = [] - - custom_admin = get_config('custom.singleuserAdmin', {}) - if spawner.user.admin and custom_admin: - # Setup admin mounts only for admins - for c in pod.spec.containers: - if c.name == "notebook": - notebook_container = c - break - else: - raise Exception("No container named 'notebook' found in pod definition") - - admin_volume_mounts = custom_admin.get('extraVolumeMounts', {}) - # custom.singleuserAdmin.extraVolumeMounts is a dict now - admin_volume_mounts = list(admin_volume_mounts.values()) - notebook_container.volume_mounts += [get_k8s_model(V1VolumeMount, obj) for obj in (admin_volume_mounts)] - - # Setup iptables blocking for everyone - block_ports = [2049, 20048, 111] - commands = [] - for protocol in ("tcp", "udp"): - for port in block_ports: - commands.append([ - "iptables", - "--append", "OUTPUT", - "--protocol", protocol, - "--destination-port", str(port), - "--jump", "DROP" - ]) - - shell_command = " && ".join([shlex.join(c) for c in commands]) - - iptables_container = { - "name": "block-nfs-access", - "image": "quay.io/jupyterhub/k8s-network-tools:4.1.0", - "securityContext": { - "runAsUser": 0, - "privileged": True, - "capabilities": { - "add": ["NET_ADMIN"] - } - }, - "command": [ - "/bin/sh", - "-c", - shell_command - ], - } - - pod.spec.init_containers.append(get_k8s_model(V1Container, iptables_container)) - - return pod - c.KubeSpawner.modify_pod_hook = modify_pod_hook - 03-2i2c-add-staff-user-ids-to-admin-users: | - from z2jh import get_config - add_staff_user_ids_to_admin_users = get_config("custom.2i2c.add_staff_user_ids_to_admin_users", False) - - if add_staff_user_ids_to_admin_users: - user_id_type = get_config("custom.2i2c.add_staff_user_ids_of_type") - staff_user_ids = get_config(f"custom.2i2c.staff_{user_id_type}_ids", []) - # `c.Authenticator.admin_users` can contain additional admins, can be an empty list, - # or it cannot be defined at all. - # This should cover all these cases. - staff_user_ids.extend(get_config("hub.config.Authenticator.admin_users", [])) - c.Authenticator.admin_users = staff_user_ids - - 04-per-user-disk: | - # Optionally, create a PVC per user - useful for per-user databases - from jupyterhub.utils import exponential_backoff - from z2jh import get_config - from kubespawner.objects import make_pvc - from functools import partial - - def make_extra_pvc(component, name_template, storage_class, storage_capacity, spawner): - """ - Create a PVC object with given spec - """ - labels = spawner._build_common_labels({}) - labels.update({ - 'component': component - }) - annotations = spawner._build_common_annotations({}) - storage_selector = spawner._expand_all(spawner.storage_selector) - return make_pvc( - name=spawner._expand_all(name_template), - storage_class=storage_class, - access_modes=['ReadWriteOnce'], - selector={}, - storage=storage_capacity, - labels=labels, - annotations=annotations - ) - - extra_user_pvcs = get_config('custom.singleuser.extraPVCs', {}) - if extra_user_pvcs: - make_db_pvc = partial(make_extra_pvc, 'db-storage', 'db-{username}', 'standard', '1G') - - pvc_makers = [partial( - make_extra_pvc, - "extra-pvc", - p["name"], - p["class"], - p["capacity"] - ) for p in extra_user_pvcs] - - async def ensure_db_pvc(spawner): - """" - Ensure a PVC is created for this user's database volume - """ - for pvc_maker in pvc_makers: - pvc = pvc_maker(spawner) - # If there's a timeout, just let it propagate - await exponential_backoff( - partial(spawner._make_create_pvc_request, pvc, spawner.k8s_api_request_timeout), - f'Could not create pvc {pvc.metadata.name}', - # Each req should be given k8s_api_request_timeout seconds. - timeout=spawner.k8s_api_request_retry_timeout - ) - c.Spawner.pre_spawn_hook = ensure_db_pvc - 05-profile-groups: | - # Re-assignes c.KubeSpawner.profile_list to a callable that filters the - # initial configuration of profile_list based on the user's github - # org/team membership as declared via "allowed_groups" read from - # profile_list profiles. - # - # This only has effect if: - # - # - GitHubOAuthenticator is used. - # - GitHubOAuthenticator.populate_teams_in_auth_state is True, that - # requires Authenticator.enable_auth_state to be True as well. - # - The user is a normal user, and not "deployment-service-check". - # - from copy import deepcopy - - from functools import partial - from textwrap import dedent - from tornado import web - from oauthenticator.github import GitHubOAuthenticator - from z2jh import get_config - - async def profile_list_allowed_groups_filter(original_profile_list, spawner): - """ - Returns the initially configured profile_list filtered based on the - user's membership in each profile's `allowed_groups`. If - `allowed_groups` isn't set for a profile, that profile is allowed for - everyone. Similar functionality is provided for both `unlisted_choice` and - `choice` inside `profile_options`. - - `allowed_groups` is a list of JupyterHub groups, set up by the authenticator. - In addition, for use with GitHubOAuthenticator, it can be a list of - teams the user is a part of, of form ':'. - - If the returned profile_list is filtered to not include any profiles, - an error is raised and the user isn't allowed to start a server. - """ - if spawner.user.name == "deployment-service-check": - print("Ignoring allowed_groups check for deployment-service-check") - return original_profile_list - - # casefold group names so we can do case insensitive comparisons. - groups = {g.name.casefold() for g in spawner.user.groups} - - # If we're using GitHubOAuthenticator, add the user's teams to the groups as well. - # Eventually this can be removed, as the user's teams can be set to be groups - # once https://github.com/jupyterhub/oauthenticator/pull/735 is merged - if isinstance(spawner.authenticator, GitHubOAuthenticator): - # Ensure auth_state is populated with teams info - auth_state = await spawner.user.get_auth_state() - if not auth_state or "teams" not in auth_state: - print(f"User {spawner.user.name} does not have any auth_state set, profile_list filtering not available") - - else: - # casefold teams to match what GitHub's API does when doing authorization calls - groups |= set([f'{team["organization"]["login"]}:{team["slug"]}'.casefold() for team in auth_state["teams"]]) - - print(f"User {spawner.user.name} is part of groups {' '.join(groups)}") - - # Filter out profiles with allowed_groups set if the user isn't part of the group - allowed_profiles = [] - for original_profile in original_profile_list: - # Make a copy, as we'll be modifying this profile - profile = deepcopy(original_profile) - - # Handle `allowed_groups` specified in profile_options - if 'profile_options' in profile: - for k, po in profile['profile_options'].items(): - - # If `unlisted_choice` has an `allowed_groups` and the current - # user is not present in any of those teams, we delete the - # `unlisted_choice` config entirely for this option. The user - # will then not be allowed to 'write in' a value. - if 'unlisted_choice' in po: - if 'allowed_groups' in po['unlisted_choice']: - if not (set(po['unlisted_choice']['allowed_groups']) & groups): - del po['unlisted_choice'] - - if 'choices' in po: - new_choices = {} - for k, c in po['choices'].items(): - # If `allowed_groups` is not set for a profile option, it is automatically - # allowed for everyone - if 'allowed_groups' not in c: - new_choices[k] = c - # If `allowed_groups` *is* set for a profile option, it is allowed only for - # members of that team. - else: - allowed_groups = set([g.casefold() for g in c['allowed_groups']]) - if allowed_groups & groups: - new_choices[k] = c - po['choices'] = new_choices - - if 'allowed_groups' not in profile: - allowed_profiles.append(profile) - else: - allowed_groups = set([g.casefold() for g in profile.get("allowed_groups", [])]) - - if allowed_groups & groups: - print(f"Allowing profile {profile['display_name']} for user {spawner.user.name} based on team membership") - allowed_profiles.append(profile) - continue - - if len(allowed_profiles) == 0: - # If no profiles are allowed, user should not be able to spawn anything! - # If we don't explicitly stop this, user will be logged into the 'default' settings - # set in singleuser, without any profile overrides. Not desired behavior - # FIXME: User doesn't actually see this error message, just the generic 403. - error_msg = dedent(f""" - Your JupyterHub group membership is insufficient to launch any server profiles. - - JupyterHub groups you are a member of are {', '.join(groups)}. - - If you are part of additional groups, log out of this JupyterHub and log back in to refresh that information. - """) - raise web.HTTPError(403, error_msg) - - return allowed_profiles - - # Only set our custom filter if - # profile_list is specified (otherwise users will get an empty screen when trying to launch servers) - if c.KubeSpawner.profile_list: - # Customize list of profiles dynamically, rather than override options form. - # This is more secure, as users can't override the options available to them via the hub API - # We pass in a copy of the original profile_list set in config via partial, to reduce possible variable - # capture related issues. - c.KubeSpawner.profile_list = partial( - profile_list_allowed_groups_filter, - deepcopy(c.KubeSpawner.profile_list) - ) - 06-salted-username: | - # Allow anonymizing username to not store *any* PII - import json - import os - import base64 - import hashlib - from z2jh import get_config - - - def salt_username(authenticator, handler, auth_model): - # Combine parts of user info with different provenances to eliminate - # possible deanonym attacks when things get leaked. - - # FIXME: Provide useful error message when using an auth provider that - # doesn't give us 'oidc' - # FIXME: Raise error if this is attempted to be used with anything other than CILogon - USERNAME_DERIVATION_PEPPER = bytes.fromhex(os.environ['USERNAME_DERIVATION_PEPPER']) - cilogon_user = auth_model['auth_state']['cilogon_user'] - user_key_parts = { - # Opaque ID from CILogon - "sub": cilogon_user['sub'], - # Combined together, opaque ID from upstream IDP (GitHub, Google, etc) - "idp": cilogon_user['idp'], - "oidc": cilogon_user['oidc'] - } - - # Use JSON here, so we don't have to deal with picking a string - # delimiter that will not appear in any of the parts. - # keys are sorted to ensure stable output over time - user_key = json.dumps(user_key_parts, sort_keys=True).encode('utf-8') - - # The cryptographic choices made here are: - # - Use blake2, because it's fairly modern - # - Set blake2 to output 32 bytes as output, which is good enough for our use case - # - Use base32 encoding, as it will produce maximum of 56 characters - # for 32 bytes output by blake2. We have 63 character username - # limits in many parts of our code (particularly, in usernames - # being part of labels in kubernetes pods), so this helps - # - Convert everything to lowercase, as base64.b32encode produces - # all uppercase characters by default. Our usernames are preferably - # lowercase, as uppercase characters must be encoded for kubernetes' - # sake - # - strip the = padding provided by base64.b32encode. This is present - # primarily to be able to determine length of the original byte - # sequence accurately. We don't care about that here. Also = is - # encoded in kubernetes and puts us over the 63 char limit. - # - Use blake2 here explicitly as a keyed hash, rather than use - # hmac. This is the canonical way to do this, and helps make it - # clearer that we want it to output 32byte hashes. We could have - # used a 16byte hash here for shorter usernames, but it is unclear - # what that does to the security properties. So better safe than - # sorry, and stick to 32bytes (rather than the default 64) - digested_user_key = base64.b32encode(hashlib.blake2b( - user_key, - key=USERNAME_DERIVATION_PEPPER, - digest_size=32 - ).digest()).decode('utf-8').lower().rstrip("=") - - # Replace the default name with our digested name, thus - # discarding the default name - auth_model["name"] = digested_user_key - - return auth_model - - if get_config('custom.auth.anonymizeUsername', False): - # https://jupyterhub.readthedocs.io/en/stable/reference/api/auth.html#jupyterhub.auth.Authenticator.post_auth_hook - c.Authenticator.post_auth_hook = salt_username - - 07-enable-fancy-profiles: | - from jupyterhub_fancy_profiles import setup_ui - setup_ui(c) - - 08-auth-state-groups: | - # Pass the user's GitHub teams from auth_state to JupyterHub group memberships. - - async def custom_auth_state_groups_key(auth_state): - if "teams" not in auth_state.keys(): - print("No GitHub teams found in auth_state.") - return None - else: - groups_list = [] - for team in auth_state["teams"]: - if f'{team["organization"]["login"]}:{team["slug"]}' not in c.GitHubOAuthenticator.allowed_organizations: - continue - else: - groups_list.append(f'{team["organization"]["login"]}:{team["slug"]}') - custom_auth_state_groups_key.groups_list = groups_list - return groups_list - if c.JupyterHub.authenticator_class == "github": - c.GitHubOAuthenticator.auth_state_groups_key = custom_auth_state_groups_key - - 09-2i2c-add-staff-gh-team: | - # Appends 2i2c staff access by GitHub team membership by default for GitHub authenticated hubs. - if c.JupyterHub.authenticator_class == "github" and type(c.GitHubOAuthenticator.allowed_organizations) == list: - c.GitHubOAuthenticator.allowed_organizations.append("2i2c-org:hub-access-for-2i2c-staff") - elif c.JupyterHub.authenticator_class == "github": - print("No GitHubOAuthenticator.allowed_organizations found, setting to ['2i2c-org:hub-access-for-2i2c-staff']") - c.GitHubOAuthenticator.allowed_organizations = ["2i2c-org:hub-access-for-2i2c-staff"] - 10-skip_refresh_for_test_user.py: | - def refresh_user_hook(authenticator, user, auth_state): - if user.name == "deployment-service-check": - # if this is the user, - # refresh_user doesn't make sense - # consider it always fresh - return True - # for all other users, refresh as usual - return None - - c.OAuthenticator.refresh_user_hook = refresh_user_hook - - # Initially copied from https://github.com/dask/helm-chart/blob/main/daskhub/values.yaml - daskhub-01-add-dask-gateway-values: | - # 1. Sets `DASK_GATEWAY__PROXY_ADDRESS` in the singleuser environment. - # 2. Adds the URL for the Dask Gateway JupyterHub service. - import os - from z2jh import get_config - - if get_config('custom.daskhubSetup.enabled'): - # Default all users on hubs with dask-gateway to use JupyterLab - c.Spawner.default_url = '/lab' - - # Add an extra label that allows user pods to talk to the proxy pod - # in clusters with networkPolicy enabled so kernels can talk to the - # dask-gateway service via the proxy - c.KubeSpawner.extra_labels.update({ - "hub.jupyter.org/network-access-proxy-http": "true" - }) - # These are set by jupyterhub. - release_name = os.environ["HELM_RELEASE_NAME"] - release_namespace = os.environ["POD_NAMESPACE"] - if "PROXY_HTTP_SERVICE_HOST" in os.environ: - # https is enabled, we want to use the internal http service. - gateway_address = "http://{}:{}/services/dask-gateway/".format( - os.environ["PROXY_HTTP_SERVICE_HOST"], - os.environ["PROXY_HTTP_SERVICE_PORT"], - ) - print("Setting DASK_GATEWAY__ADDRESS {} from HTTP service".format(gateway_address)) - else: - gateway_address = "http://proxy-public/services/dask-gateway" - print("Setting DASK_GATEWAY__ADDRESS {}".format(gateway_address)) - # Internal address to connect to the Dask Gateway. - c.KubeSpawner.environment.setdefault("DASK_GATEWAY__ADDRESS", gateway_address) - # Internal address for the Dask Gateway proxy. - c.KubeSpawner.environment.setdefault("DASK_GATEWAY__PROXY_ADDRESS", "gateway://traefik-{}-dask-gateway.{}:80".format(release_name, release_namespace)) - # Relative address for the dashboard link. - c.KubeSpawner.environment.setdefault("DASK_GATEWAY__PUBLIC_ADDRESS", "/services/dask-gateway/") - # Use JupyterHub to authenticate with Dask Gateway. - c.KubeSpawner.environment.setdefault("DASK_GATEWAY__AUTH__TYPE", "jupyterhub") - - # Add some settings for dask gateway via environment variables - # https://docs.dask.org/en/latest/configuration.html has more information - # Kubernetes env variable expansion via `{{}}` is used here. See - # https://kubernetes.io/docs/tasks/inject-data-application/define-interdependent-environment-variables/ - # for more information - c.KubeSpawner.environment.update({ - # Specify what image dask-gateway workers and schedulers should use - 'DASK_GATEWAY__CLUSTER__OPTIONS__IMAGE': '{{JUPYTER_IMAGE_SPEC}}', - 'DASK_GATEWAY__CLUSTER__OPTIONS__ENVIRONMENT': '{{"SCRATCH_BUCKET": "$(SCRATCH_BUCKET)", "PANGEO_SCRATCH": "$(PANGEO_SCRATCH)"}}', - 'DASK_DISTRIBUTED__DASHBOARD__LINK': '{{JUPYTERHUB_SERVICE_PREFIX}}proxy/{{port}}/status' - }) - - # Adds Dask Gateway as a JupyterHub service to make the gateway available at - # {HUB_URL}/services/dask-gateway - service_url = "http://traefik-{}-dask-gateway.{}".format(release_name, release_namespace) - for service in c.JupyterHub.services: - if service["name"] == "dask-gateway": - if not service.get("url", None): - print("Adding dask-gateway service URL") - service.setdefault("url", service_url) - break - else: - print("dask-gateway service not found, this should not happen!") jupyterhub-home-nfs: enabled: true