From 4b6ac08ab0b0bb375a19b7e7903a26e0d184ba8a Mon Sep 17 00:00:00 2001 From: stellarshenson Date: Wed, 14 Jan 2026 16:15:21 +0100 Subject: [PATCH] feat: idle server culler for automatic shutdown of inactive servers - Add jupyterhub-idle-culler package to Dockerfile - Configure as managed JupyterHub service with role-based scopes - Environment variables: IDLE_CULLER_ENABLED, IDLE_CULLER_TIMEOUT, IDLE_CULLER_CULL_EVERY, IDLE_CULLER_MAX_AGE - Default: disabled, 24h timeout, 10min check interval - Bump version to 3.6.0 --- .claude/JOURNAL.md | 3 ++ README.md | 19 ++++++++++ config/jupyterhub_config.py | 43 ++++++++++++++++++++++- project.env | 4 +-- services/jupyterhub/Dockerfile.jupyterhub | 5 +-- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.claude/JOURNAL.md b/.claude/JOURNAL.md index 868a2ef..503f517 100644 --- a/.claude/JOURNAL.md +++ b/.claude/JOURNAL.md @@ -138,3 +138,6 @@ This journal tracks substantive work on documents, diagrams, and documentation c 45. **Task - Selective notification recipients**: Enhanced notification broadcast to allow targeting specific servers
**Result**: Added ActiveServersHandler (`GET /api/notifications/active-servers`) to list active servers. Modified BroadcastNotificationHandler to accept optional `recipients` array - filters to selected users if provided, sends to all if omitted (backward compatible). Updated notifications.html with "Send to all active servers" checkbox, server selection list with Select All/Deselect All buttons, dynamic button text showing recipient count. Validation prevents sending with no recipients selected + +46. **Task - Idle server culler**: Implemented automatic shutdown of inactive servers
+ **Result**: Added jupyterhub-idle-culler package to Dockerfile. Added configuration with environment variables: IDLE_CULLER_ENABLED (default 0), IDLE_CULLER_TIMEOUT (default 86400s/24h), IDLE_CULLER_CULL_EVERY (default 600s/10min), IDLE_CULLER_MAX_AGE (default 0/unlimited). Service runs as managed JupyterHub service with role-based scopes. Disabled by default, opt-in via IDLE_CULLER_ENABLED=1. Bumped version to 3.6.0 diff --git a/README.md b/README.md index f7c6ba3..497c350 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Multi-user JupyterHub 4 deployment platform with data science stack, GPU support - **Native Authentication**: Built-in user management with NativeAuthenticator supporting optional self-registration (`ENABLE_SIGNUP`) and admin approval. Authorization page protects existing users from accidental discard - only pending signup requests can be discarded - **Admin User Creation**: Batch user creation from admin panel with auto-generated mnemonic passwords (e.g., `storm-apple-ocean`). Credentials modal with copy/download options - **Shared Storage**: Optional CIFS/NAS mount support for shared datasets across all users +- **Idle Server Culler**: Automatic shutdown of inactive servers after configurable timeout (default: 24 hours). Frees resources when users leave servers running - **Production Ready**: Traefik reverse proxy with TLS termination, automatic container updates via Watchtower ## User Interface @@ -360,6 +361,24 @@ services: - ENABLE_SIGNUP=0 # disable self-registration, admin creates users ``` +#### Idle Server Culler + +Automatically stop user servers after a period of inactivity to free up resources. Disabled by default. + +```yaml +services: + jupyterhub: + environment: + - IDLE_CULLER_ENABLED=1 # enable idle culler + - IDLE_CULLER_TIMEOUT=86400 # 24 hours (default) - stop after this many seconds of inactivity + - IDLE_CULLER_CULL_EVERY=600 # 10 minutes (default) - how often to check for idle servers + - IDLE_CULLER_MAX_AGE=0 # 0 (default) - max server age regardless of activity (0=unlimited) +``` + +**Behavior**: +- `IDLE_CULLER_TIMEOUT`: Server is stopped after this many seconds without activity. Active servers are never culled +- `IDLE_CULLER_MAX_AGE`: Force stop servers older than this (useful to force image updates). Set to 0 to disable + #### Custom Branding Replace the default JupyterHub logo with a custom logo. Mount your logo file and reference it via environment variable: diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py index c88e527..524a6d3 100644 --- a/config/jupyterhub_config.py +++ b/config/jupyterhub_config.py @@ -188,7 +188,11 @@ ENABLE_GPU_SUPPORT = int(os.environ.get("ENABLE_GPU_SUPPORT", 2)) ENABLE_SERVICE_MLFLOW = int(os.environ.get("ENABLE_SERVICE_MLFLOW", 1)) ENABLE_SERVICE_GLANCES = int(os.environ.get("ENABLE_SERVICE_GLANCES", 1)) ENABLE_SERVICE_TENSORBOARD = int(os.environ.get("ENABLE_SERVICE_TENSORBOARD", 1)) -ENABLE_SIGNUP = int(os.environ.get("ENABLE_SIGNUP", 1)) # 0 - disabled (admin creates users), 1 - enabled (self-registration) +ENABLE_SIGNUP = int(os.environ.get("ENABLE_SIGNUP", 1)) # 0 - disabled (admin creates users), 1 - enabled (self-registration) +IDLE_CULLER_ENABLED = int(os.environ.get("IDLE_CULLER_ENABLED", 0)) # 0 - disabled, 1 - enabled +IDLE_CULLER_TIMEOUT = int(os.environ.get("IDLE_CULLER_TIMEOUT", 86400)) # idle timeout in seconds (default: 24 hours) +IDLE_CULLER_CULL_EVERY = int(os.environ.get("IDLE_CULLER_CULL_EVERY", 600)) # check interval in seconds (default: 10 min) +IDLE_CULLER_MAX_AGE = int(os.environ.get("IDLE_CULLER_MAX_AGE", 0)) # max server age in seconds (0 = unlimited) TF_CPP_MIN_LOG_LEVEL = int(os.environ.get("TF_CPP_MIN_LOG_LEVEL", 3)) DOCKER_NOTEBOOK_DIR = "/home/lab/workspace" JUPYTERHUB_BASE_URL = os.environ.get("JUPYTERHUB_BASE_URL") @@ -421,4 +425,41 @@ if c is not None: (r'/notifications', NotificationsPageHandler), ] + # Idle culler service - automatically stops servers after inactivity + if IDLE_CULLER_ENABLED == 1: + import sys + + # Define role with required scopes for idle culler + c.JupyterHub.load_roles = [ + { + "name": "jupyterhub-idle-culler-role", + "scopes": [ + "list:users", + "read:users:activity", + "read:servers", + "delete:servers", + ], + "services": ["jupyterhub-idle-culler"], + } + ] + + # Configure idle culler service + culler_cmd = [ + sys.executable, + "-m", "jupyterhub_idle_culler", + f"--timeout={IDLE_CULLER_TIMEOUT}", + f"--cull-every={IDLE_CULLER_CULL_EVERY}", + ] + if IDLE_CULLER_MAX_AGE > 0: + culler_cmd.append(f"--max-age={IDLE_CULLER_MAX_AGE}") + + c.JupyterHub.services = [ + { + "name": "jupyterhub-idle-culler", + "command": culler_cmd, + } + ] + + print(f"[Idle Culler] Enabled - timeout={IDLE_CULLER_TIMEOUT}s, check every={IDLE_CULLER_CULL_EVERY}s, max_age={IDLE_CULLER_MAX_AGE}s") + # EOF diff --git a/project.env b/project.env index 1ae8a52..9846073 100644 --- a/project.env +++ b/project.env @@ -3,8 +3,8 @@ PROJECT_NAME="stellars-jupyterhub-ds" PROJECT_DESCRIPTION="Multi-user JupyterHub 4 deployment platform with data science stack, GPU auto-detection, NativeAuthenticator, and isolated per-user environments spawned via DockerSpawner" # Version -VERSION="3.5.46_cuda-12.9.1_jh-5.4.2" -VERSION_COMMENT="Admin user creation with auto-generated passwords, NativeAuth sync, custom templates" +VERSION="3.6.0_cuda-12.9.1_jh-5.4.2" +VERSION_COMMENT="Idle server culler, selective notifications, volume encoding fix" RELEASE_TAG="RELEASE_3.2.11" RELEASE_DATE="2025-11-09" diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub index d5147da..a155003 100644 --- a/services/jupyterhub/Dockerfile.jupyterhub +++ b/services/jupyterhub/Dockerfile.jupyterhub @@ -43,11 +43,12 @@ COPY --chmod=644 services/jupyterhub/templates_enhanced/*.html /srv/jupyterhub/ 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 -## install dockerspawner, nativeauthenticator +## install dockerspawner, nativeauthenticator, idle-culler RUN pip install -U --no-cache-dir \ docker \ dockerspawner \ - jupyterhub-nativeauthenticator + jupyterhub-nativeauthenticator \ + jupyterhub-idle-culler ## copy custom.css to JupyterHub's static directory RUN <<-EOF