diff --git a/helm-generic/Makefile b/helm-generic/Makefile index b034ba8..0518c31 100644 --- a/helm-generic/Makefile +++ b/helm-generic/Makefile @@ -1,16 +1,15 @@ -# Makefile for managing JupyterHub deployment on Kubernetes using Helm -# # This Makefile provides targets to: # - Update Helm chart dependencies -# - Create and delete Kubernetes namespaces and secrets -# - Deploy, uninstall, and check the status of the JupyterHub Helm release -# - Retrieve Ingress information for accessing JupyterHub +# - Create and delete Kubernetes secrets for JupyterHub configuration +# - Deploy, uninstall # Configuration +# --- CONFIG_FILE ?= config.mk -include $(CONFIG_FILE) # Kubernetes context +# --- DEFAULT_KUBE_CONTEXT := $(shell kubectl config current-context 2>/dev/null) ifeq ($(strip $(KUBE_CONTEXT)),) KUBE_CONTEXT := $(if $(DEFAULT_KUBE_CONTEXT),$(DEFAULT_KUBE_CONTEXT),microk8s) @@ -18,46 +17,57 @@ endif KUBECTL := kubectl $(if $(KUBE_CONTEXT),--context $(KUBE_CONTEXT)) # Helm release and namespace configuration +# --- HELM_RELEASE ?= ndp-jhub # Name of the Helm release NAMESPACE ?= ndp-jhub # Define values files and spawner config +# --- VALUES_FILE := ndp-hub/values.yaml SITE_VALUES_FILE ?= ndp-hub/site-values.yaml SPAWNER_FILE := ndp-hub/spawner.py # Overrides values from site-values.yaml +# --- VALUES_ARGS := -f $(VALUES_FILE) ifneq ($(wildcard $(SITE_VALUES_FILE)),) VALUES_ARGS += -f $(SITE_VALUES_FILE) endif - # Embed spawner config file into jhub helm chart values SPAWNER_SET_ARG := --set-file jupyterhub.hub.extraConfig.default\\\.py=$(SPAWNER_FILE) + .PHONY: update create-ns create-jhub-secret delete-jhub-secret deploy uninstall status get-ingress + + # Updates Helm chart dependencies by downloading them into the charts/ directory update: helm dependency update ndp-hub + # Create namespace if it does not exist create-ns: @echo "Creating namespace $(NAMESPACE) if it does not exist..." $(KUBECTL) get namespace $(NAMESPACE) >/dev/null 2>&1 || $(KUBECTL) create namespace $(NAMESPACE) + # Create a kubernetes secret for keycloak client_id and client_secret create-jhub-secret: create-ns @echo "Creating JupyterHub secret in namespace $(NAMESPACE)..." $(KUBECTL) create secret generic jupyterhub-secret \ --from-file=values.yaml=ndp-hub/jupyterhub_secret.yaml \ --namespace $(NAMESPACE) + $(KUBECTL) create secret generic jupyterhub-metrics-secret --from-literal=metrics-api-key=placeholder -n $(NAMESPACE) + # Delete the kubernetes secret for keycloak client_id and client_secret delete-jhub-secret: @echo "Deleting JupyterHub secret in namespace $(NAMESPACE)..." $(KUBECTL) delete secret jupyterhub-secret --namespace $(NAMESPACE) || true + $(KUBECTL) delete secret jupyterhub-metrics-secret --namespace $(NAMESPACE) || true + # Deploy the Helm chart deploy: @@ -70,21 +80,10 @@ deploy: $(VALUES_ARGS) \ $(SPAWNER_SET_ARG) + # Uninstall the Helm chart uninstall: @echo "Uninstalling $(HELM_RELEASE) from $(NAMESPACE)..." helm uninstall $(HELM_RELEASE) \ --kube-context $(KUBE_CONTEXT) \ --namespace $(NAMESPACE) - -# Check the status of the Helm release -status: - @echo "Checking status of $(HELM_RELEASE) in $(NAMESPACE)..." - helm status $(HELM_RELEASE) \ - --kube-context $(KUBE_CONTEXT) \ - --namespace $(NAMESPACE) - -# Get the Ingress IP for accessing JupyterHub -get-ingress: - @echo "Fetching Ingress details for $(HELM_RELEASE) ..." - @$(KUBECTL) get ingress -n $(NAMESPACE) -o wide diff --git a/helm-generic/README.md b/helm-generic/README.md index 7e4f08d..512e7c4 100644 --- a/helm-generic/README.md +++ b/helm-generic/README.md @@ -80,10 +80,8 @@ For more information on `kubectl` and `helm`, refer to the following resources: ## **Access your JupyterHub** `http:///jupyter/` -## **Optional follow-up**: verify and cleanup +## **Cleanup** -`make status`: Run to confirm helm release is healthy.
-`make get-ingress`: Grab the ingress endpoint when you need the URL.
`make uninstall`: Remove the helm release.
`make delete-jhub-secret`: Delete the jupyterhub-secret from the cluster. diff --git a/helm-generic/k8s_hub_docker_image/build-and-push.sh b/helm-generic/k8s_hub_docker_image/build-and-push.sh deleted file mode 100644 index 12976d0..0000000 --- a/helm-generic/k8s_hub_docker_image/build-and-push.sh +++ /dev/null @@ -1,8 +0,0 @@ -# Build and push a multi-platform (amd64, arm64) Docker image using Buildx. - -# Update 'yutianqin/ndp-k8s-hub:' as needed. -# '--no-cache' is used to ensure that no cached layers are utilized, forcing a fresh build. -# '-f ./Dockerfile' explicitly specifies the Dockerfile to be used. -# The final context for the build is the current directory ('.') - -docker buildx build --platform linux/amd64,linux/arm64 -t yutianqin/ndp-k8s-hub:v1 --push --no-cache -f ./Dockerfile . \ No newline at end of file diff --git a/helm-generic/k8s_hub_docker_image/templates/403.html b/helm-generic/k8s_hub_docker_image/templates/403.html index 7af2344..06d0a86 100644 --- a/helm-generic/k8s_hub_docker_image/templates/403.html +++ b/helm-generic/k8s_hub_docker_image/templates/403.html @@ -1,10 +1,13 @@ -{% extends "error.html" %} +{% extends "page.html" %} - {% block h1_error %} - {% endblock h1_error %} - -{% block error_detail %} - -

Please contact National Data Platform administrators to get JupyterHub access at admin email

+{% block login_widget %}{% endblock %} +{% block main %} +
+

Access Denied

+
+

You do not have permission to access this JupyterHub.

+

To request access, please contact the National Data Platform administrators at admin@utah.edu.

+ Logout +
{% endblock %} diff --git a/helm-generic/ndp-hub/spawner.py b/helm-generic/ndp-hub/spawner.py index d29057a..359434a 100644 --- a/helm-generic/ndp-hub/spawner.py +++ b/helm-generic/ndp-hub/spawner.py @@ -54,20 +54,21 @@ def use_k8s_secret(namespace, secret_name): original_profile_list = [ { - 'display_name': "NDP-EP/SciDx Remote Execution Environment", + 'display_name': "NDP-EP/SciDx ALL-IN-ONE Demo", 'default': False, - 'slug': "13", + 'slug': "14", 'kubespawner_override': { - 'image': 'yutianqin/scidx-rexec-quickstart:latest', + 'image': "ghcr.io/sci-ndp/ndp-ep-demo-allinone:latest", 'image_pull_policy': 'Always', } }, { - 'display_name': "NDP Endpoint Data Streaming & Data Staging Examples", + 'display_name': "NDP-EP/SciDx Remote Execution Environment", 'default': False, - 'slug': "12", + 'slug': "13", 'kubespawner_override': { - 'image': 'yutianqin/rai-utah-hackathon:latest', + 'image': 'yutianqin/scidx-rexec-quickstart:latest', + 'image_pull_policy': 'Always', } }, { @@ -179,6 +180,11 @@ class MySpawner(KubeSpawner): @@ -272,7 +281,7 @@ async def options_from_form(self, formdata): setattr(self, k, image) selected_id = formdata.get('entity', [''])[0] - + selected_id = formdata.get('entity', [''])[0] entity_name = '' workspace_id = '' @@ -283,7 +292,7 @@ async def options_from_form(self, formdata): workspace_id = entity.get('workspace_id', '') entity_id = entity.get('entity_id', '') break - + self.entity_id = entity_id self.workspace_id = workspace_id self.entity_name = entity_name @@ -335,10 +344,28 @@ async def options_from_form(self, formdata): async def options_form(self, spawner): try: - entities = await get_user_entities(spawner.access_token) + # spawner.access_token is set by auth_state_hook only when the server starts, + # so it holds the token from the last server launch (could be stale). + # Always read fresh auth_state from the DB and attempt a token refresh so + # the workspace API call uses a valid token regardless of auth_refresh_age. + auth_state = await spawner.user.get_auth_state() + if not auth_state: + raise RuntimeError("No auth state found for user") + refreshed = spawner.authenticator.check_refresh_token_keycloak(auth_state) + if refreshed: + access_token, new_refresh = refreshed + auth_state['access_token'] = access_token + auth_state['refresh_token'] = new_refresh + await spawner.user.save_auth_state(auth_state) + else: + access_token = auth_state.get('access_token') + entities = await get_user_entities(access_token) except Exception as e: - self.log.error(f"Error fetching entities: {e}") - entities = [] + print(f"Error fetching entities: {e}") + entities = [{ + "entity_id": "expired", + "name": "Session expired. Please log out and log in again." + }] # Grab ?source=... from request (if present) source = None @@ -460,12 +487,12 @@ async def get_user_pvcs(token): conn.request("GET", "/workspaces-api/read_pvc_by_user", headers=headers) response = conn.getresponse() if response.status != 200: - return [] + raise RuntimeError(f"Request failed with status {response.status}: {response.reason}") data = json.loads(response.read().decode("utf-8")) return data except (http.client.HTTPException, TimeoutError, json.JSONDecodeError, socket.timeout): return [] - + ## Function to retrieve user entities async def get_user_entities(token): try: @@ -523,6 +550,8 @@ async def pre_spawn_hook(spawner): pip_install_command1 = ("pip install --upgrade jupyterlab==4.2.4 jupyter-archive==3.4.0 jupyterlab-launchpad==1.0.1") pip_install_command2 = ("pip install jupyterlab-git==0.50.1 --index-url https://gitlab.nrp-nautilus.io/api/v4/projects/3930/packages/pypi/simple --user") pip_install_command3 = (f"pip install ndp-jupyterlab-extension=={NDP_EXT_VERSION} --index-url https://gitlab.nrp-nautilus.io/api/v4/projects/3930/packages/pypi/simple --user") + pip_install_command4 = ("pip install 'pexpect>=4.9' --user") # fix for git clone issue + pelican_exe_command = 'wget -O - "https://dl.pelicanplatform.org/latest/pelican_$(uname -s)_$(uname -m).tar.gz" | tar zx -C /home/jovyan/.local/bin/ --strip-components=1' # Modify the spawner's start command to include the pip install original_cmd = spawner.cmd or ["jupyterhub-singleuser"] @@ -535,8 +564,10 @@ async def pre_spawn_hook(spawner): f"&& {pip_install_command1} || true " f"&& {pip_install_command2} || true " f"&& {pip_install_command3} || true " + f"&& {pip_install_command4} || true " + f"&& {pelican_exe_command} || true " f"&& cd /home/jovyan/work || true " - f"&& exec {' '.join(original_cmd)}" + f"&& exec {' '.join(original_cmd)} '--ServerApp.allow_origin=*'" # Allow CORS if behind a proxy ] username = spawner.user.name @@ -584,7 +615,6 @@ async def pre_spawn_hook(spawner): 'spec': { 'accessModes': ['ReadWriteMany'], 'resources': {'requests': {'storage': '5Gi'}}, - # 'storageClassName': 'rook-cephfs-central' 'storageClassName': STORAGE_CLASS } } diff --git a/helm-generic/ndp-hub/values.yaml b/helm-generic/ndp-hub/values.yaml index edc51c3..8d2f961 100644 --- a/helm-generic/ndp-hub/values.yaml +++ b/helm-generic/ndp-hub/values.yaml @@ -3,7 +3,7 @@ jupyterhub: hub: image: name: yutianqin/ndp-k8s-hub - tag: "v1" + tag: "latest" extraEnv: JUPYTERHUB_METRICS_API_KEY: valueFrom: diff --git a/helm/ndp-hub/spawner_test.py b/helm/ndp-hub/spawner_test.py index cb85114..e828a3f 100644 --- a/helm/ndp-hub/spawner_test.py +++ b/helm/ndp-hub/spawner_test.py @@ -458,7 +458,18 @@ async def options_from_form(self, formdata): async def options_form(self, spawner): try: - entities = await get_user_entities(spawner.access_token) + auth_state = await spawner.user.get_auth_state() + if not auth_state: + raise RuntimeError("No auth state found for user") + refreshed = spawner.authenticator.check_refresh_token_keycloak(auth_state) + if refreshed: + access_token, new_refresh = refreshed + auth_state['access_token'] = access_token + auth_state['refresh_token'] = new_refresh + await spawner.user.save_auth_state(auth_state) + else: + access_token = auth_state.get('access_token') + entities = await get_user_entities(access_token) except Exception as e: print(f"Error fetching entities: {e}") entities = [{