From d8a306a0d537ef0427ee0fd29c82bb3499135b09 Mon Sep 17 00:00:00 2001 From: stellarshenson Date: Wed, 14 Jan 2026 16:43:26 +0100 Subject: [PATCH] feat: externalize settings metadata to YAML dictionary - Add config/settings_dictionary.yml with category-based structure - Categories as top-level keys (JupyterHub Core, Docker Spawner, etc.) - Each setting has name, description, default, optional empty_display - Update SettingsPageHandler to load from YAML file - Add pyyaml to Dockerfile pip dependencies - Refactor pip install to heredoc format with echo message --- .claude/JOURNAL.md | 3 + config/settings_dictionary.yml | 79 +++++++++++++++++++ services/jupyterhub/Dockerfile.jupyterhub | 17 ++-- .../jupyterhub/conf/bin/custom_handlers.py | 75 ++++++++++-------- 4 files changed, 137 insertions(+), 37 deletions(-) create mode 100644 config/settings_dictionary.yml diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 93f2739..1ac9286 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -144,3 +144,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c 47. **Task - Standardize env vars with JUPYTERHUB_ prefix**: Renamed all environment variables to use consistent JUPYTERHUB_ prefix and added admin Settings page
**Result**: Renamed 13 environment variables (ENABLE_GPU_SUPPORT->JUPYTERHUB_GPU_ENABLED, ENABLE_JUPYTERHUB_SSL->JUPYTERHUB_SSL_ENABLED, ENABLE_SERVICE_*->JUPYTERHUB_SERVICE_*, ENABLE_SIGNUP->JUPYTERHUB_SIGNUP_ENABLED, DOCKER_NOTEBOOK_IMAGE->JUPYTERHUB_NOTEBOOK_IMAGE, DOCKER_NETWORK_NAME->JUPYTERHUB_NETWORK_NAME, NVIDIA_AUTODETECT_IMAGE->JUPYTERHUB_NVIDIA_IMAGE, IDLE_CULLER_*->JUPYTERHUB_IDLE_CULLER_*). Created SettingsPageHandler with admin-only access at /settings showing read-only banded table of all configuration values. Updated compose.yml, jupyterhub_config.py, custom_handlers.py, README.md, CLAUDE.md. Added Settings link to admin navbar. No backward compatibility for old names. Bumped version to 3.6.1 + +48. **Task - Settings dictionary YAML**: Externalized settings metadata to config/settings_dictionary.yml
+ **Result**: Created settings_dictionary.yml with categories as top-level keys (JupyterHub Core, Docker Spawner, GPU, Services, Idle Culler, Branding), each containing list of settings with name, description, default, and optional empty_display. Updated SettingsPageHandler to load from YAML instead of hardcoded values. Added pyyaml to Dockerfile pip install. Dockerfile now copies settings_dictionary.yml to /srv/jupyterhub/ diff --git a/config/settings_dictionary.yml b/config/settings_dictionary.yml new file mode 100644 index 0000000..aed5b2f --- /dev/null +++ b/config/settings_dictionary.yml @@ -0,0 +1,79 @@ +# Settings Dictionary +# Defines environment variables displayed on the admin Settings page +# Categories are top-level keys, each containing a list of settings +# Each setting has: name (env var), description (UI label), default (fallback value) + +JupyterHub Core: + - name: JUPYTERHUB_ADMIN + description: Admin username + default: admin + + - name: JUPYTERHUB_BASE_URL + description: Base URL path + default: /jupyterhub + + - name: JUPYTERHUB_SIGNUP_ENABLED + description: User self-registration (0=disabled, 1=enabled) + default: "1" + + - name: JUPYTERHUB_SSL_ENABLED + description: SSL/TLS (0=disabled, 1=enabled) + default: "1" + +Docker Spawner: + - name: JUPYTERHUB_NOTEBOOK_IMAGE + description: User container image + default: stellars/stellars-jupyterlab-ds:latest + + - name: JUPYTERHUB_NETWORK_NAME + description: Docker network for containers + default: jupyterhub_network + +GPU: + - name: JUPYTERHUB_GPU_ENABLED + description: GPU support (0=disabled, 1=enabled, 2=auto-detect) + default: "2" + + - name: JUPYTERHUB_NVIDIA_IMAGE + description: CUDA image for GPU detection + default: nvidia/cuda:12.9.1-base-ubuntu24.04 + +Services: + - name: JUPYTERHUB_SERVICE_MLFLOW + description: MLflow service (0=disabled, 1=enabled) + default: "1" + + - name: JUPYTERHUB_SERVICE_GLANCES + description: Glances service (0=disabled, 1=enabled) + default: "1" + + - name: JUPYTERHUB_SERVICE_TENSORBOARD + description: TensorBoard service (0=disabled, 1=enabled) + default: "1" + +Idle Culler: + - name: JUPYTERHUB_IDLE_CULLER_ENABLED + description: Idle culler (0=disabled, 1=enabled) + default: "0" + + - name: JUPYTERHUB_IDLE_CULLER_TIMEOUT + description: Idle timeout in seconds + default: "86400" + + - name: JUPYTERHUB_IDLE_CULLER_INTERVAL + description: Check interval in seconds + default: "600" + + - name: JUPYTERHUB_IDLE_CULLER_MAX_AGE + description: Max server age (0=unlimited) + default: "0" + +Branding: + - name: JUPYTERHUB_CUSTOM_LOGO_URI + description: Custom logo URI + default: "" + empty_display: "(default)" + + - name: STELLARS_JUPYTERHUB_VERSION + description: Platform version + default: unknown diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub index a155003..1ae0d8b 100644 --- a/services/jupyterhub/Dockerfile.jupyterhub +++ b/services/jupyterhub/Dockerfile.jupyterhub @@ -42,13 +42,18 @@ COPY --chmod=600 services/jupyterhub/templates/certs /mnt/certs COPY --chmod=644 services/jupyterhub/templates_enhanced/*.html /srv/jupyterhub/templates/ COPY --chmod=644 services/jupyterhub/templates_enhanced/static/custom.css /tmp/custom.css COPY --chmod=644 config/jupyterhub_config.py /srv/jupyterhub/jupyterhub_config.py +COPY --chmod=644 config/settings_dictionary.yml /srv/jupyterhub/settings_dictionary.yml -## install dockerspawner, nativeauthenticator, idle-culler -RUN pip install -U --no-cache-dir \ - docker \ - dockerspawner \ - jupyterhub-nativeauthenticator \ - jupyterhub-idle-culler +## install dockerspawner, nativeauthenticator, idle-culler, pyyaml +RUN <<-EOF + echo "installing core jupyterhub python packages" + pip install -U --no-cache-dir \ + docker \ + dockerspawner \ + jupyterhub-nativeauthenticator \ + jupyterhub-idle-culler \ + pyyaml +EOF ## copy custom.css to JupyterHub's static directory RUN <<-EOF diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py index f5de323..515c6d8 100755 --- a/services/jupyterhub/conf/bin/custom_handlers.py +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -566,6 +566,9 @@ class GetUserCredentialsHandler(BaseHandler): class SettingsPageHandler(BaseHandler): """Handler for rendering the settings page (admin only, read-only)""" + # Settings dictionary file path + SETTINGS_DICT_PATH = "/srv/jupyterhub/settings_dictionary.yml" + @web.authenticated async def get(self): """ @@ -581,38 +584,48 @@ class SettingsPageHandler(BaseHandler): self.log.info(f"[Settings Page] Admin {current_user.name} accessed settings panel") - # Collect environment variables and settings - settings = [] - - # JupyterHub Core Settings - settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_ADMIN", "value": os.environ.get("JUPYTERHUB_ADMIN", "admin"), "description": "Admin username"}) - settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_BASE_URL", "value": os.environ.get("JUPYTERHUB_BASE_URL", "/jupyterhub"), "description": "Base URL path"}) - settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SIGNUP_ENABLED", "value": os.environ.get("JUPYTERHUB_SIGNUP_ENABLED", "1"), "description": "User self-registration (0=disabled, 1=enabled)"}) - settings.append({"category": "JupyterHub Core", "name": "JUPYTERHUB_SSL_ENABLED", "value": os.environ.get("JUPYTERHUB_SSL_ENABLED", "1"), "description": "SSL/TLS (0=disabled, 1=enabled)"}) - - # Docker/Spawner Settings - settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NOTEBOOK_IMAGE", "value": os.environ.get("JUPYTERHUB_NOTEBOOK_IMAGE", "stellars/stellars-jupyterlab-ds:latest"), "description": "User container image"}) - settings.append({"category": "Docker Spawner", "name": "JUPYTERHUB_NETWORK_NAME", "value": os.environ.get("JUPYTERHUB_NETWORK_NAME", "jupyterhub_network"), "description": "Docker network for containers"}) - - # GPU Settings - settings.append({"category": "GPU", "name": "JUPYTERHUB_GPU_ENABLED", "value": os.environ.get("JUPYTERHUB_GPU_ENABLED", "2"), "description": "GPU support (0=disabled, 1=enabled, 2=auto-detect)"}) - settings.append({"category": "GPU", "name": "JUPYTERHUB_NVIDIA_IMAGE", "value": os.environ.get("JUPYTERHUB_NVIDIA_IMAGE", "nvidia/cuda:12.9.1-base-ubuntu24.04"), "description": "CUDA image for GPU detection"}) - - # Services - settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_MLFLOW", "value": os.environ.get("JUPYTERHUB_SERVICE_MLFLOW", "1"), "description": "MLflow service (0=disabled, 1=enabled)"}) - settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_GLANCES", "value": os.environ.get("JUPYTERHUB_SERVICE_GLANCES", "1"), "description": "Glances service (0=disabled, 1=enabled)"}) - settings.append({"category": "Services", "name": "JUPYTERHUB_SERVICE_TENSORBOARD", "value": os.environ.get("JUPYTERHUB_SERVICE_TENSORBOARD", "1"), "description": "TensorBoard service (0=disabled, 1=enabled)"}) - - # Idle Culler - settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_ENABLED", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_ENABLED", "0"), "description": "Idle culler (0=disabled, 1=enabled)"}) - settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_TIMEOUT", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_TIMEOUT", "86400"), "description": "Idle timeout in seconds"}) - settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_INTERVAL", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_INTERVAL", "600"), "description": "Check interval in seconds"}) - settings.append({"category": "Idle Culler", "name": "JUPYTERHUB_IDLE_CULLER_MAX_AGE", "value": os.environ.get("JUPYTERHUB_IDLE_CULLER_MAX_AGE", "0"), "description": "Max server age (0=unlimited)"}) - - # Branding - settings.append({"category": "Branding", "name": "JUPYTERHUB_CUSTOM_LOGO_URI", "value": os.environ.get("JUPYTERHUB_CUSTOM_LOGO_URI", "") or "(default)", "description": "Custom logo URI"}) - settings.append({"category": "Branding", "name": "STELLARS_JUPYTERHUB_VERSION", "value": os.environ.get("STELLARS_JUPYTERHUB_VERSION", "unknown"), "description": "Platform version"}) + # Load settings from YAML dictionary + settings = self._load_settings() # Render the template html = self.render_template("settings.html", sync=True, user=current_user, settings=settings) self.finish(html) + + def _load_settings(self): + """Load settings from YAML dictionary file and populate with env values""" + import yaml + + settings = [] + + try: + with open(self.SETTINGS_DICT_PATH, 'r') as f: + config = yaml.safe_load(f) + + # Categories are top-level keys in the YAML + for category, items in config.items(): + # Skip comment lines (strings starting with #) + if not isinstance(items, list): + continue + + for item in items: + name = item.get('name', '') + default = str(item.get('default', '')) + value = os.environ.get(name, default) + + # Handle empty_display for empty values + if not value and 'empty_display' in item: + value = item['empty_display'] + + settings.append({ + "category": category, + "name": name, + "value": value, + "description": item.get('description', '') + }) + + except FileNotFoundError: + self.log.error(f"[Settings Page] Settings dictionary not found: {self.SETTINGS_DICT_PATH}") + except Exception as e: + self.log.error(f"[Settings Page] Error loading settings dictionary: {e}") + + return settings