set fallback := true export JUPYTERHUB_NAMESPACE := env("JUPYTERHUB_NAMESPACE", "datastack") export JUPYTERHUB_CHART_VERSION := env("JUPYTERHUB_CHART_VERSION", "4.2.0") export JUPYTERHUB_OIDC_CLIENT_ID := env("JUPYTERHUB_OIDC_CLIENT_ID", "jupyterhub") export JUPYTERHUB_OIDC_CLIENT_SESSION_IDLE := env("JUPYTERHUB_OIDC_CLIENT_SESSION_IDLE", "86400") export JUPYTERHUB_OIDC_CLIENT_SESSION_MAX := env("JUPYTERHUB_OIDC_CLIENT_SESSION_MAX", "604800") export JUPYTERHUB_NFS_PV_ENABLED := env("JUPYTERHUB_NFS_PV_ENABLED", "") export JUPYTERHUB_STORAGE_CLASS := env("JUPYTERHUB_STORAGE_CLASS", "") export JUPYTERHUB_VAULT_INTEGRATION_ENABLED := env("JUPYTERHUB_VAULT_INTEGRATION_ENABLED", "") export JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED := env("JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED", "") export JUPYTER_PYTHON_KERNEL_TAG := env("JUPYTER_PYTHON_KERNEL_TAG", "python-3.12-53") export KERNEL_IMAGE_BUUN_STACK_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_REPOSITORY", "buun-stack-notebook") export KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY := env("KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY", "buun-stack-cuda-notebook") export JUPYTER_PROFILE_MINIMAL_ENABLED := env("JUPYTER_PROFILE_MINIMAL_ENABLED", "false") export JUPYTER_PROFILE_BASE_ENABLED := env("JUPYTER_PROFILE_BASE_ENABLED", "false") export JUPYTER_PROFILE_DATASCIENCE_ENABLED := env("JUPYTER_PROFILE_DATASCIENCE_ENABLED", "true") export JUPYTER_PROFILE_PYSPARK_ENABLED := env("JUPYTER_PROFILE_PYSPARK_ENABLED", "false") export JUPYTER_PROFILE_PYTORCH_ENABLED := env("JUPYTER_PROFILE_PYTORCH_ENABLED", "false") export JUPYTER_PROFILE_TENSORFLOW_ENABLED := env("JUPYTER_PROFILE_TENSORFLOW_ENABLED", "false") export JUPYTER_PROFILE_BUUN_STACK_ENABLED := env("JUPYTER_PROFILE_BUUN_STACK_ENABLED", "false") export JUPYTER_PROFILE_BUUN_STACK_CUDA_ENABLED := env("JUPYTER_PROFILE_BUUN_STACK_CUDA_ENABLED", "false") export JUPYTERHUB_GPU_ENABLED := env("JUPYTERHUB_GPU_ENABLED", "") export JUPYTERHUB_GPU_LIMIT := env("JUPYTERHUB_GPU_LIMIT", "1") export JUPYTER_MCP_SERVER_ENABLED := env("JUPYTER_MCP_SERVER_ENABLED", "") export JUPYTERHUB_VAULT_TOKEN_TTL := env("JUPYTERHUB_VAULT_TOKEN_TTL", "24h") export NOTEBOOK_VAULT_TOKEN_TTL := env("NOTEBOOK_VAULT_TOKEN_TTL", "24h") export NOTEBOOK_VAULT_TOKEN_MAX_TTL := env("NOTEBOOK_VAULT_TOKEN_MAX_TTL", "168h") export JUPYTERHUB_CULL_MAX_AGE := env("JUPYTERHUB_CULL_MAX_AGE", "604800") export VAULT_AGENT_LOG_LEVEL := env("VAULT_AGENT_LOG_LEVEL", "info") export JUPYTER_BUUNSTACK_LOG_LEVEL := env("JUPYTER_BUUNSTACK_LOG_LEVEL", "warning") export IMAGE_REGISTRY := env("IMAGE_REGISTRY", "localhost:30500") export SPARK_DOWNLOAD_URL := env("SPARK_DOWNLOAD_URL", "https://dlcdn.apache.org/spark/") export SPARK_VERSION := env("SPARK_VERSION", "4.0.1") export PIP_REPOSITORY_URL := env("PIP_REPOSITORY_URL", "https://pypi.org/simple/") export AIRFLOW_DAGS_STORAGE_SIZE := env("AIRFLOW_DAGS_STORAGE_SIZE", "10Gi") export LONGHORN_NAMESPACE := env("LONGHORN_NAMESPACE", "longhorn") export KEYCLOAK_REALM := env("KEYCLOAK_REALM", "buunstack") export VAULT_HOST := env("VAULT_HOST", "") export VAULT_ADDR := "https://" + VAULT_HOST export MONITORING_ENABLED := env("MONITORING_ENABLED", "") export PROMETHEUS_NAMESPACE := env("PROMETHEUS_NAMESPACE", "monitoring") export DOCKER_CMD := env("DOCKER_CMD", "docker") export EXTERNAL_SECRETS_NAMESPACE := env("EXTERNAL_SECRETS_NAMESPACE", "external-secrets") export K8S_VAULT_NAMESPACE := env("K8S_VAULT_NAMESPACE", "vault") [private] default: @just --list --unsorted --list-submodules # Add Helm repository add-helm-repo: helm repo add jupyterhub https://jupyterhub.github.io/helm-chart helm repo update # Remove Helm repository remove-helm-repo: helm repo remove jupyterhub # Create JupyterHub namespace create-namespace: kubectl get namespace ${JUPYTERHUB_NAMESPACE} &>/dev/null || \ kubectl create namespace ${JUPYTERHUB_NAMESPACE} # Delete JupyterHub namespace delete-namespace: kubectl delete namespace ${JUPYTERHUB_NAMESPACE} --ignore-not-found # Create JupyterHub admin service token secret create-admin-service-token-secret: #!/bin/bash set -euo pipefail admin_token=$(just utils::random-password) if helm status external-secrets -n ${EXTERNAL_SECRETS_NAMESPACE} &>/dev/null; then echo "External Secrets Operator detected. Storing admin service token in Vault..." just vault::put jupyterhub/admin-service token="${admin_token}" kubectl delete secret jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found kubectl delete externalsecret jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found gomplate -f jupyterhub-admin-service-token-external-secret.gomplate.yaml \ -o jupyterhub-admin-service-token-external-secret.yaml kubectl apply -f jupyterhub-admin-service-token-external-secret.yaml echo "Waiting for ExternalSecret to sync..." kubectl wait --for=condition=Ready externalsecret/jupyterhub-admin-service-token \ -n ${JUPYTERHUB_NAMESPACE} --timeout=60s else echo "External Secrets Operator not found. Creating secret directly..." kubectl delete secret jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found kubectl create secret generic jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} \ --from-literal=token="${admin_token}" if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then just vault::put jupyterhub/admin-service token="${admin_token}" fi fi # Create JupyterHub crypt key secret create-crypt-key-secret: #!/bin/bash set -euo pipefail crypt_key=$(just utils::random-password) if helm status external-secrets -n ${EXTERNAL_SECRETS_NAMESPACE} &>/dev/null; then echo "External Secrets Operator detected. Storing crypt key in Vault..." just vault::put jupyterhub/config crypt-key="${crypt_key}" kubectl delete secret jupyterhub-crypt-key -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found kubectl delete externalsecret jupyterhub-crypt-key -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found gomplate -f jupyterhub-crypt-key-external-secret.gomplate.yaml \ -o jupyterhub-crypt-key-external-secret.yaml kubectl apply -f jupyterhub-crypt-key-external-secret.yaml echo "Waiting for ExternalSecret to sync..." kubectl wait --for=condition=Ready externalsecret/jupyterhub-crypt-key \ -n ${JUPYTERHUB_NAMESPACE} --timeout=60s else echo "External Secrets Operator not found. Creating secret directly..." kubectl delete secret jupyterhub-crypt-key -n ${JUPYTERHUB_NAMESPACE} --ignore-not-found kubectl create secret generic jupyterhub-crypt-key -n ${JUPYTERHUB_NAMESPACE} \ --from-literal=crypt-key="${crypt_key}" if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then just vault::put jupyterhub/config crypt-key="${crypt_key}" fi fi # Install JupyterHub install root_token='': #!/bin/bash set -euo pipefail export JUPYTERHUB_HOST=${JUPYTERHUB_HOST:-} while [ -z "${JUPYTERHUB_HOST}" ]; do JUPYTERHUB_HOST=$( gum input --prompt="JupyterHub host (FQDN): " --width=100 \ --placeholder="e.g., jupyter.example.com" ) done just create-namespace kubectl label namespace ${JUPYTERHUB_NAMESPACE} \ pod-security.kubernetes.io/enforce=restricted --overwrite if ! kubectl get secret jupyterhub-crypt-key -n ${JUPYTERHUB_NAMESPACE} &>/dev/null; then just create-crypt-key-secret fi if ! kubectl get secret jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} &>/dev/null; then just create-admin-service-token-secret fi if helm status kube-prometheus-stack -n ${PROMETHEUS_NAMESPACE} &>/dev/null; then if [ -z "${MONITORING_ENABLED}" ]; then if gum confirm "Enable Prometheus monitoring (ServiceMonitor)?"; then MONITORING_ENABLED="true" else MONITORING_ENABLED="false" fi fi else MONITORING_ENABLED="false" fi if helm status nvidia-device-plugin -n ${NVIDIA_DEVICE_PLUGIN_NAMESPACE:-nvidia-device-plugin} &>/dev/null; then if [ -z "${JUPYTERHUB_GPU_ENABLED}" ]; then if gum confirm "Enable GPU support for JupyterHub notebooks?"; then JUPYTERHUB_GPU_ENABLED="true" if [ -z "${JUPYTERHUB_GPU_LIMIT}" ]; then JUPYTERHUB_GPU_LIMIT=$( gum input --prompt="GPU limit per user (default: 1): " --width=100 \ --placeholder="1" --value="1" ) fi else JUPYTERHUB_GPU_ENABLED="false" fi fi else JUPYTERHUB_GPU_ENABLED="false" fi # just k8s::copy-regcred ${JUPYTERHUB_NAMESPACE} just keycloak::create-client realm=${KEYCLOAK_REALM} client_id=${JUPYTERHUB_OIDC_CLIENT_ID} \ redirect_url="https://${JUPYTERHUB_HOST}/hub/oauth_callback" \ client_secret="" client_session_idle="${JUPYTERHUB_OIDC_CLIENT_SESSION_IDLE}" client_session_max="${JUPYTERHUB_OIDC_CLIENT_SESSION_MAX}" just add-helm-repo export JUPYTERHUB_OIDC_CLIENT_ID=${JUPYTERHUB_OIDC_CLIENT_ID} export KEYCLOAK_REALM=${KEYCLOAK_REALM} export JUPYTER_PYTHON_KERNEL_TAG=${JUPYTER_PYTHON_KERNEL_TAG} export JUPYTER_FSGID=${JUPYTER_FSGID:-100} export PVC_NAME="" if [ -z "${JUPYTERHUB_NFS_PV_ENABLED}" ]; then if gum confirm "Are you going to use NFS PV?"; then JUPYTERHUB_NFS_PV_ENABLED=true else JUPYTERHUB_NFS_PV_ENABLED=false fi fi if [ "${JUPYTERHUB_NFS_PV_ENABLED}" = "true" ]; then if ! helm status longhorn -n ${LONGHORN_NAMESPACE} &>/dev/null; then echo "Longhorn is not installed. Please install Longhorn first." >&2 exit 1 fi JUPYTERHUB_STORAGE_CLASS=${JUPYTERHUB_STORAGE_CLASS:-longhorn} export JUPYTER_NFS_IP=${JUPYTER_NFS_IP:-} while [ -z "${JUPYTER_NFS_IP}" ]; do JUPYTER_NFS_IP=$( gum input --prompt="NFS server IP address: " --width=100 \ --placeholder="e.g., 192.168.10.1" ) done export JUPYTER_NFS_PATH=${JUPYTER_NFS_PATH:-} while [ -z "${JUPYTER_NFS_PATH}" ]; do JUPYTER_NFS_PATH=$( gum input --prompt="NFS server export path: " --width=100 \ --placeholder="e.g., /volume1/drive1/jupyter" ) done PVC_NAME=jupyter-nfs-pvc # Create StorageClass for NFS static provisioning if ! kubectl get storageclass jupyter-nfs-static &>/dev/null; then kubectl apply -f jupyter-nfs-storage-class.yaml fi if ! kubectl get pv jupyter-nfs-pv &>/dev/null; then gomplate -f nfs-pv.gomplate.yaml | kubectl apply -f - fi kubectl apply -n ${JUPYTERHUB_NAMESPACE} -f nfs-pvc.yaml fi if [ -z "${JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED}" ]; then if gum confirm "Enable Airflow DAG storage mounting (requires Airflow in same namespace)?"; then JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED="true" else JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED="false" fi fi if [ "${JUPYTERHUB_AIRFLOW_DAGS_PERSISTENCE_ENABLED}" = "true" ]; then echo "✅ Airflow DAG mounting enabled" echo " Note: Airflow must be installed in the same namespace (jupyter)" echo " PVC: airflow-dags-pvc will be mounted at /opt/airflow-dags" echo "" echo " ⚠️ If you install Airflow AFTER JupyterHub, restart user pods to mount DAGs:" echo " kubectl delete pods -n jupyter -l app.kubernetes.io/component=singleuser-server" fi if [ -z "${JUPYTER_MCP_SERVER_ENABLED}" ]; then if gum confirm "Enable jupyter-mcp-server?"; then JUPYTER_MCP_SERVER_ENABLED="true" else JUPYTER_MCP_SERVER_ENABLED="false" fi fi if [ "${JUPYTER_MCP_SERVER_ENABLED}" = "true" ]; then echo "✅ jupyter-mcp-server enabled" echo " MCP endpoint: https://${JUPYTERHUB_HOST}/user/{username}/mcp" echo " Use 'just jupyterhub::get-token ' to get API token" fi if [ -z "${JUPYTERHUB_VAULT_INTEGRATION_ENABLED}" ]; then if gum confirm "Are you going to enable Vault integration?"; then JUPYTERHUB_VAULT_INTEGRATION_ENABLED=true else JUPYTERHUB_VAULT_INTEGRATION_ENABLED=false fi fi if [ "${JUPYTERHUB_VAULT_INTEGRATION_ENABLED}" = "true" ]; then echo "Setting up Vault Agent for automatic token management..." echo " Token TTL: ${JUPYTERHUB_VAULT_TOKEN_TTL}" export VAULT_TOKEN="{{ root_token }}" while [ -z "${VAULT_TOKEN}" ]; do VAULT_TOKEN=$(gum input --prompt="Vault root token: " --password --width=100) done just setup-vault-integration ${VAULT_TOKEN} just create-jupyterhub-vault-token ${VAULT_TOKEN} # Create ExternalSecret for admin vault token echo "Creating ExternalSecret for admin vault token..." gomplate -f jupyterhub-vault-token-external-secret.gomplate.yaml | kubectl apply -f - # Read user policy template for Vault export USER_POLICY_HCL=$(cat user_policy.hcl) else echo "Vault integration disabled - deploying without Vault support" export USER_POLICY_HCL="" fi echo "Generating pre_spawn_hook.py..." gomplate -f pre_spawn_hook.gomplate.py -o pre_spawn_hook.py # https://z2jh.jupyter.org/en/stable/ gomplate -f jupyterhub-values.gomplate.yaml -o jupyterhub-values.yaml helm upgrade --cleanup-on-fail --install jupyterhub jupyterhub/jupyterhub \ --version ${JUPYTERHUB_CHART_VERSION} -n ${JUPYTERHUB_NAMESPACE} \ --timeout=20m -f jupyterhub-values.yaml # Wait deployments manually because `helm upgrade --wait` does not work for JupyterHub just k8s::wait-deployments-ready ${JUPYTERHUB_NAMESPACE} hub proxy if [ "${MONITORING_ENABLED}" = "true" ]; then echo "Enabling Prometheus monitoring for namespace ${JUPYTERHUB_NAMESPACE}..." kubectl label namespace ${JUPYTERHUB_NAMESPACE} buun.channel/enable-monitoring=true --overwrite echo "Deploying ServiceMonitor for Prometheus..." gomplate -f jupyterhub-servicemonitor.gomplate.yaml | kubectl apply -f - echo "✓ ServiceMonitor deployed" fi # Uninstall JupyterHub uninstall: #!/bin/bash set -euo pipefail helm uninstall jupyterhub -n ${JUPYTERHUB_NAMESPACE} --wait --ignore-not-found kubectl delete pods -n ${JUPYTERHUB_NAMESPACE} -l app.kubernetes.io/component=singleuser-server kubectl delete -n ${JUPYTERHUB_NAMESPACE} pvc jupyter-nfs-pvc --ignore-not-found kubectl delete -n ${JUPYTERHUB_NAMESPACE} secret jupyterhub-crypt-key --ignore-not-found kubectl delete -n ${JUPYTERHUB_NAMESPACE} secret jupyterhub-admin-service-token --ignore-not-found kubectl delete -n ${JUPYTERHUB_NAMESPACE} externalsecret jupyterhub-crypt-key --ignore-not-found kubectl delete -n ${JUPYTERHUB_NAMESPACE} externalsecret jupyterhub-admin-service-token --ignore-not-found kubectl delete -n ${JUPYTERHUB_NAMESPACE} externalsecret jupyterhub-vault-token --ignore-not-found if kubectl get pv jupyter-nfs-pv &>/dev/null; then kubectl patch pv jupyter-nfs-pv -p '{"spec":{"claimRef":null}}' fi # Clean up Vault entries if present if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then just vault::delete jupyterhub/config || true just vault::delete jupyterhub/admin-service || true fi # Delete JupyterHub PV and StorageClass delete-pv: #!/bin/bash set -euo pipefail if kubectl get pv jupyter-nfs-pv &>/dev/null; then kubectl patch pv jupyter-nfs-pv -p '{"spec":{"claimRef":null}}' kubectl delete pv jupyter-nfs-pv fi kubectl delete storageclass jupyter-nfs-static --ignore-not-found # Build Jupyter notebook kernel images build-kernel-images: #!/bin/bash set -euo pipefail ( cd ../python-package rm -rf dist/ build/ *.egg-info/ SETUPTOOLS_SCM_PRETEND_VERSION_FOR_BUUNSTACK=0.1.0 python -m build --wheel ) ( cd ./images/datastack-notebook cp ../../../python-package/dist/*.whl ./ DOCKER_BUILDKIT=1 ${DOCKER_CMD} build -t \ ${IMAGE_REGISTRY}/${KERNEL_IMAGE_BUUN_STACK_REPOSITORY}:${JUPYTER_PYTHON_KERNEL_TAG} \ --build-arg spark_version="${SPARK_VERSION}" \ --build-arg spark_download_url="${SPARK_DOWNLOAD_URL}" \ --build-arg pip_repository_url="${PIP_REPOSITORY_URL}" \ . ) rm -f ./images/datastack-notebook/*.whl if [ "${JUPYTER_PROFILE_BUUN_STACK_CUDA_ENABLED}" = "true" ]; then ( cd ./images/datastack-cuda-notebook cp ../../../python-package/dist/*.whl ./ DOCKER_BUILDKIT=1 ${DOCKER_CMD} build -t \ ${IMAGE_REGISTRY}/${KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY}:${JUPYTER_PYTHON_KERNEL_TAG} \ --build-arg spark_version="${SPARK_VERSION}" \ --build-arg spark_download_url="${SPARK_DOWNLOAD_URL}" \ --build-arg pip_repository_url="${PIP_REPOSITORY_URL}" \ . ) rm -f ./images/datastack-cuda-notebook/*.whl fi # Push Jupyter notebook kernel images push-kernel-images: #!/bin/bash set -euo pipefail ${DOCKER_CMD} push ${IMAGE_REGISTRY}/${KERNEL_IMAGE_BUUN_STACK_REPOSITORY}:${JUPYTER_PYTHON_KERNEL_TAG} if [ "${JUPYTER_PROFILE_BUUN_STACK_CUDA_ENABLED}" = "true" ]; then ${DOCKER_CMD} push ${IMAGE_REGISTRY}/${KERNEL_IMAGE_BUUN_STACK_CUDA_REPOSITORY}:${JUPYTER_PYTHON_KERNEL_TAG} fi # Setup Vault integration for JupyterHub (user-specific tokens + auto-renewal) setup-vault-integration root_token='': #!/bin/bash set -euo pipefail export VAULT_TOKEN="{{ root_token }}" while [ -z "${VAULT_TOKEN}" ]; do VAULT_TOKEN=$(gum input --prompt="Vault root token: " --password --width=100) done echo "Setting up Vault integration for JupyterHub..." # Create JupyterHub-specific policy and Kubernetes role in Vault echo "Creating JupyterHub-specific Vault policy and Kubernetes role..." echo " Service Account: hub" echo " Namespace: jupyter" echo " Policy: jupyterhub-admin (custom policy with extended max TTL)" echo " TTL: ${JUPYTERHUB_VAULT_TOKEN_TTL}" # Create JupyterHub-specific policy echo "Creating jupyterhub-admin policy..." vault policy write jupyterhub-admin jupyterhub-admin-policy.hcl # Create Kubernetes role (use system-safe max_ttl to avoid warnings) echo "Creating Kubernetes role..." vault write auth/kubernetes/role/jupyterhub-admin \ bound_service_account_names=hub \ bound_service_account_namespaces=jupyter \ policies=jupyterhub-admin \ audience=vault \ ttl=${JUPYTERHUB_VAULT_TOKEN_TTL} \ max_ttl=720h # Create ConfigMap with token renewal script echo "Creating ConfigMap with token renewal script..." kubectl create configmap vault-token-renewer-config -n ${JUPYTERHUB_NAMESPACE} \ --from-file=vault-token-renewer.sh=vault-token-renewer.sh \ --dry-run=client -o yaml | kubectl apply -f - echo "✓ Vault integration configured (user-specific tokens + auto-renewal)" echo "" echo "Configuration Summary:" echo " JupyterHub Token TTL: ${JUPYTERHUB_VAULT_TOKEN_TTL}" echo " User Token TTL: ${NOTEBOOK_VAULT_TOKEN_TTL}" echo " User Token Max TTL: ${NOTEBOOK_VAULT_TOKEN_MAX_TTL}" echo " Vault Agent Log Level: ${VAULT_AGENT_LOG_LEVEL}" echo " Auto-renewal: Every TTL/2 (minimum 30s) based on actual token TTL" echo "" echo "Users can now access Vault from notebooks using:" echo " from buunstack import SecretStore" echo " secrets = SecretStore()" echo " # Each user gets their own isolated Vault token and policy" echo " # Admin token is automatically renewed by Vault Agent" # Create JupyterHub Vault token (renewable with unlimited Max TTL) create-jupyterhub-vault-token root_token='': #!/bin/bash set -euo pipefail export VAULT_TOKEN="{{ root_token }}" while [ -z "${VAULT_TOKEN}" ]; do VAULT_TOKEN=$(gum input --prompt="Vault root token: " --password --width=100) done # Create admin vault token with unlimited max TTL echo "" echo "Creating admin token (TTL: ${JUPYTERHUB_VAULT_TOKEN_TTL}, Max TTL: unlimited)..." TOKEN_RESPONSE=$(vault token create \ -policy=jupyterhub-admin \ -ttl=${JUPYTERHUB_VAULT_TOKEN_TTL} \ -explicit-max-ttl=0 \ -display-name="jupyterhub-admin" \ -renewable=true \ -format=json) # Extract token ADMIN_TOKEN=$(echo "$TOKEN_RESPONSE" | jq -r .auth.client_token) if [ -z "$ADMIN_TOKEN" ] || [ "$ADMIN_TOKEN" = "null" ]; then echo "❌ Failed to create admin token" exit 1 fi # Store token in Vault for JupyterHub to retrieve echo "Storing admin token in Vault..." vault kv put secret/jupyterhub/vault-token token="$ADMIN_TOKEN" echo "" echo "✅ Admin token created and stored successfully!" echo "" echo "Token behavior:" echo " - TTL: ${JUPYTERHUB_VAULT_TOKEN_TTL} (will expire without renewal)" echo " - Max TTL: Unlimited (can be renewed forever)" echo " - Vault Agent will renew at TTL/2 intervals (minimum 30s)" echo "" echo "Token stored at: secret/jupyterhub/vault-token" # Get or create JupyterHub API token for a user get-token username: #!/bin/bash set -euo pipefail USERNAME="{{ username }}" if [ -z "${USERNAME}" ]; then echo "Error: Username is required" >&2 echo "Usage: just jupyterhub::get-token " >&2 exit 1 fi # Get admin service token from Secret ADMIN_TOKEN=$(kubectl get secret jupyterhub-admin-service-token -n ${JUPYTERHUB_NAMESPACE} \ -o jsonpath='{.data.token}' 2>/dev/null | base64 -d || true) if [ -z "${ADMIN_TOKEN}" ]; then echo "Error: Could not retrieve admin service token" >&2 echo "Make sure jupyterhub-admin-service-token secret exists" >&2 exit 1 fi # Check if user exists, if not create it USER_EXISTS=$(kubectl exec -n ${JUPYTERHUB_NAMESPACE} deployment/hub -- \ curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: token ${ADMIN_TOKEN}" \ "http://localhost:8081/hub/api/users/${USERNAME}" 2>/dev/null || echo "000") if [ "${USER_EXISTS}" = "404" ]; then echo "User '${USERNAME}' not found, creating..." >&2 CREATE_RESPONSE=$(kubectl exec -n ${JUPYTERHUB_NAMESPACE} deployment/hub -- \ curl -s -X POST \ -H "Authorization: token ${ADMIN_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"usernames\": [\"${USERNAME}\"]}" \ "http://localhost:8081/hub/api/users" 2>/dev/null) echo "User '${USERNAME}' created" >&2 fi # Create token via JupyterHub API # POST /hub/api/users/{name}/tokens # Use access:servers!user={username} scope to allow access to the user's own servers RESPONSE=$(kubectl exec -n ${JUPYTERHUB_NAMESPACE} deployment/hub -- \ curl -s -X POST \ -H "Authorization: token ${ADMIN_TOKEN}" \ -H "Content-Type: application/json" \ -d "{\"note\": \"MCP server token\", \"scopes\": [\"access:servers!user=${USERNAME}\", \"self\"]}" \ "http://localhost:8081/hub/api/users/${USERNAME}/tokens" 2>/dev/null) TOKEN=$(echo "${RESPONSE}" | jq -r '.token // empty' 2>/dev/null) if [ -z "${TOKEN}" ]; then ERROR=$(echo "${RESPONSE}" | jq -r '.message // .error // empty' 2>/dev/null) if [ -n "${ERROR}" ]; then echo "Error: ${ERROR}" >&2 else echo "Error: Failed to create token for user '${USERNAME}'" >&2 echo "Response: ${RESPONSE}" >&2 fi exit 1 fi echo "${TOKEN}" # Get JupyterHub API token for a user (from running pod) get-api-token username: #!/bin/bash set -euo pipefail USERNAME="{{ username }}" if [ -z "${USERNAME}" ]; then echo "Error: Username is required" >&2 echo "Usage: just jupyterhub::get-api-token " >&2 exit 1 fi # Get the pod name for the user POD_NAME=$(kubectl get pods -n ${JUPYTERHUB_NAMESPACE} \ -l "app=jupyterhub,component=singleuser-server,hub.jupyter.org/username=${USERNAME}" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [ -z "${POD_NAME}" ]; then echo "Error: No running pod found for user '${USERNAME}'" >&2 echo "Make sure the user has an active Jupyter session" >&2 exit 1 fi # Check if pod is ready POD_STATUS=$(kubectl get pod -n ${JUPYTERHUB_NAMESPACE} ${POD_NAME} \ -o jsonpath='{.status.phase}' 2>/dev/null || true) if [ "${POD_STATUS}" != "Running" ]; then echo "Error: Pod ${POD_NAME} is not running (status: ${POD_STATUS})" >&2 exit 1 fi # Get the API token from the pod's environment API_TOKEN=$(kubectl exec -n ${JUPYTERHUB_NAMESPACE} ${POD_NAME} -- \ sh -c 'echo $JUPYTERHUB_API_TOKEN' 2>/dev/null || true) if [ -z "${API_TOKEN}" ]; then echo "Error: Could not retrieve API token from pod ${POD_NAME}" >&2 echo "The pod might not have JUPYTERHUB_API_TOKEN environment variable set" >&2 exit 1 fi echo "${API_TOKEN}" # Show MCP server configuration for a user setup-mcp-server username='': #!/bin/bash set -euo pipefail USERNAME="{{ username }}" if [ -z "${USERNAME}" ]; then USERNAME=$(gum input --prompt="JupyterHub username: " --width=100 --placeholder="e.g., buun") fi if [ -z "${USERNAME}" ]; then echo "Error: Username is required" >&2 exit 1 fi # Get the API token for the user echo "Getting API token for user '${USERNAME}'..." API_TOKEN=$(just jupyterhub::get-api-token ${USERNAME} 2>/dev/null || true) if [ -z "${API_TOKEN}" ]; then echo "Error: Could not get API token for user '${USERNAME}'" >&2 echo "Make sure the user has an active Jupyter session" >&2 exit 1 fi # MCP endpoint URL (Jupyter Server Extension provides /mcp) MCP_URL="https://${JUPYTERHUB_HOST}/user/${USERNAME}/mcp" echo "" echo "✅ MCP Server is available at: ${MCP_URL}" echo "" echo "API Token: ${API_TOKEN}" echo "" echo "MCP Server Configuration:" echo "" echo " URL: ${MCP_URL}" echo " Transport: HTTP (streamable-http)" echo " Authentication: Bearer token via Authorization header" echo "" echo "Environment variable:" echo " export JUPYTERHUB_TOKEN=${API_TOKEN}" echo "" echo "Available MCP tools:" echo " - list_files: List files in the Jupyter server" echo " - list_kernels: List available kernels" echo " - use_notebook: Activate a notebook for operations" echo " - list_notebooks: Show activated notebooks" echo " - insert_cell, execute_cell, read_cell, delete_cell: Cell operations" echo " - execute_code: Execute arbitrary code" echo "" echo "Note: Use 'list_files' first to find notebooks, then 'use_notebook' to activate one." # Show MCP server configuration for Claude Code (with .mcp.json example) setup-claude-mcp-server username='': #!/bin/bash set -euo pipefail USERNAME="{{ username }}" if [ -z "${USERNAME}" ]; then USERNAME=$(gum input --prompt="JupyterHub username: " --width=100 --placeholder="e.g., buun") fi if [ -z "${USERNAME}" ]; then echo "Error: Username is required" >&2 exit 1 fi # Get the API token for the user echo "Getting API token for user '${USERNAME}'..." API_TOKEN=$(just jupyterhub::get-api-token ${USERNAME} 2>/dev/null || true) if [ -z "${API_TOKEN}" ]; then echo "Error: Could not get API token for user '${USERNAME}'" >&2 echo "Make sure the user has an active Jupyter session" >&2 exit 1 fi # MCP endpoint URL (Jupyter Server Extension provides /mcp) MCP_URL="https://${JUPYTERHUB_HOST}/user/${USERNAME}/mcp" echo "" echo "✅ MCP Server is available at: ${MCP_URL}" echo "" echo "API Token: ${API_TOKEN}" echo "" echo "To configure Claude Code, add to your .mcp.json:" echo "" cat <&2 exit 1 fi # Check if user pod is running POD_NAME=$(kubectl get pods -n ${JUPYTERHUB_NAMESPACE} \ -l "app=jupyterhub,component=singleuser-server,hub.jupyter.org/username=${USERNAME}" \ -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || true) if [ -z "${POD_NAME}" ]; then echo "❌ No running pod found for user '${USERNAME}'" echo " Start a Jupyter session first at: https://${JUPYTERHUB_HOST}" exit 1 fi echo "✅ User pod running: ${POD_NAME}" # Check if jupyter-mcp-server extension is enabled echo "" echo "Checking jupyter-mcp-server extension status..." kubectl exec -n ${JUPYTERHUB_NAMESPACE} ${POD_NAME} -- \ jupyter server extension list 2>/dev/null | grep -E "(jupyter_mcp_server|enabled)" || \ echo "⚠️ jupyter-mcp-server extension not found in listing" # Try to access MCP endpoint echo "" echo "Testing MCP endpoint..." API_TOKEN=$(just jupyterhub::get-api-token ${USERNAME} 2>/dev/null || true) if [ -n "${API_TOKEN}" ]; then RESPONSE=$(kubectl exec -n ${JUPYTERHUB_NAMESPACE} ${POD_NAME} -- \ curl -s -o /dev/null -w "%{http_code}" \ -H "Authorization: token ${API_TOKEN}" \ "http://localhost:8888/mcp" 2>/dev/null || echo "000") if [ "${RESPONSE}" = "200" ] || [ "${RESPONSE}" = "405" ]; then echo "✅ MCP endpoint responding (HTTP ${RESPONSE})" echo " URL: https://${JUPYTERHUB_HOST}/user/${USERNAME}/mcp" else echo "⚠️ MCP endpoint returned HTTP ${RESPONSE}" fi fi