748 lines
31 KiB
Makefile
748 lines
31 KiB
Makefile
set fallback := true
|
|
|
|
export JUPYTERHUB_NAMESPACE := env("JUPYTERHUB_NAMESPACE", "datastack")
|
|
export JUPYTERHUB_CHART_VERSION := env("JUPYTERHUB_CHART_VERSION", "4.3.1")
|
|
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-54")
|
|
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 <username>' 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 <username>" >&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 <username>" >&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 <<EOF
|
|
{
|
|
"mcpServers": {
|
|
"jupyter-${USERNAME}": {
|
|
"type": "http",
|
|
"url": "${MCP_URL}",
|
|
"headers": {
|
|
"Authorization": "token \${JUPYTERHUB_TOKEN}"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
echo ""
|
|
echo "Then set the environment variable:"
|
|
echo " export JUPYTERHUB_TOKEN=${API_TOKEN}"
|
|
echo ""
|
|
echo "Or add to your shell profile (~/.bashrc, ~/.zshrc):"
|
|
echo " export JUPYTERHUB_TOKEN=${API_TOKEN}"
|
|
|
|
# Show MCP server status for a user
|
|
mcp-status 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
|
|
|
|
# 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
|