Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 17 additions & 18 deletions helm-generic/Makefile
Original file line number Diff line number Diff line change
@@ -1,63 +1,73 @@
# 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)
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:
Expand All @@ -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
4 changes: 1 addition & 3 deletions helm-generic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,8 @@ For more information on `kubectl` and `helm`, refer to the following resources:
## **Access your JupyterHub**
`http://<ingress-host>/jupyter/`

## **Optional follow-up**: verify and cleanup
## **Cleanup**

`make status`: Run to confirm helm release is healthy.<br>
`make get-ingress`: Grab the ingress endpoint when you need the URL.<br>
`make uninstall`: Remove the helm release.<br>
`make delete-jhub-secret`: Delete the jupyterhub-secret from the cluster.

Expand Down
8 changes: 0 additions & 8 deletions helm-generic/k8s_hub_docker_image/build-and-push.sh

This file was deleted.

17 changes: 10 additions & 7 deletions helm-generic/k8s_hub_docker_image/templates/403.html
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
{% extends "error.html" %}
{% extends "page.html" %}

{% block h1_error %}
{% endblock h1_error %}

{% block error_detail %}
<!-- <img src="{{static_url("images/ndp_logo.png") }}"> -->
<p>Please contact National Data Platform administrators to get JupyterHub access at <a href = "mailto: yutian.qin@utah.edu"></a>admin email</p>
{% block login_widget %}{% endblock %}

{% block main %}
<div class="container" style="margin-top: 80px; max-width: 560px;">
<h2>Access Denied</h2>
<hr>
<p>You do not have permission to access this JupyterHub.</p>
<p>To request access, please contact the National Data Platform administrators at <a href="mailto:saleem.alharir@utah.edu">admin@utah.edu</a>.</p>
<a href="{{logout_url}}" class="btn btn-primary">Logout</a>
</div>
{% endblock %}
60 changes: 45 additions & 15 deletions helm-generic/ndp-hub/spawner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
},
{
Expand Down Expand Up @@ -179,6 +180,11 @@ class MySpawner(KubeSpawner):

<label for="entity-select">What do you want to work on?</label>
<select class="form-control input" name="entity" required>
<!--
{% if entity_list and entity_list[0].entity_id == "expired" %}
<option value="" disabled selected>{{ entity_list[0].name }}</option>
{% else %}
-->
<option value="" disabled {% if not preselected_entity %}selected{% endif %}>Select</option>
{% for entity in entity_list %}
{% set value = entity.workspace_id or entity.entity_id %}
Expand All @@ -189,6 +195,9 @@ class MySpawner(KubeSpawner):
</option>
{% endif %}
{% endfor %}
<!--
{% endif %}
-->
</select>

<!-- Profile selection -->
Expand Down Expand Up @@ -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 = ''
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand Down Expand Up @@ -584,7 +615,6 @@ async def pre_spawn_hook(spawner):
'spec': {
'accessModes': ['ReadWriteMany'],
'resources': {'requests': {'storage': '5Gi'}},
# 'storageClassName': 'rook-cephfs-central'
'storageClassName': STORAGE_CLASS
}
}
Expand Down
2 changes: 1 addition & 1 deletion helm-generic/ndp-hub/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ jupyterhub:
hub:
image:
name: yutianqin/ndp-k8s-hub
tag: "v1"
tag: "latest"
extraEnv:
JUPYTERHUB_METRICS_API_KEY:
valueFrom:
Expand Down
13 changes: 12 additions & 1 deletion helm/ndp-hub/spawner_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [{
Expand Down