diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 07744b6..222477f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -156,6 +156,55 @@ volumes: User containers will access this at `/mnt/shared`. +## User Self-Service Features + +The platform provides two self-service features accessible from the user control panel (`/hub/home`): + +### Reset Home Volume + +**Purpose**: Allows users to delete their home directory volume and start fresh with a clean environment. + +**Requirements**: +- User's JupyterLab server must be stopped +- Volume `jupyterlab-{username}_home` must exist + +**Implementation**: +- API Endpoint: `DELETE /hub/api/users/{username}/reset-home-volume` +- Handler: `services/jupyterhub/conf/bin/custom_handlers.py::ResetHomeVolumeHandler` +- Uses Docker API to safely remove the volume +- Only affects home volume - workspace and cache volumes are preserved + +**Permissions**: +- Users can reset their own home volume +- Admins can reset any user's home volume +- Enforced via `@admin_or_self` decorator + +### Restart Server + +**Purpose**: Provides one-click Docker container restart without recreating the container. + +**Requirements**: +- User's JupyterLab server must be running +- Container `jupyterlab-{username}` must exist + +**Implementation**: +- API Endpoint: `POST /hub/api/users/{username}/restart-server` +- Handler: `services/jupyterhub/conf/bin/custom_handlers.py::RestartServerHandler` +- Uses Docker's native `container.restart(timeout=10)` method +- Preserves container identity, volumes, and configuration +- Does NOT recreate container (unlike JupyterHub's stop/spawn cycle) + +**Permissions**: +- Users can restart their own server +- Admins can restart any user's server +- Enforced via `@admin_or_self` decorator + +**UI Location**: +- Custom template: `services/jupyterhub/templates/home.html` +- Reset button visible when server is stopped +- Restart button visible when server is running +- Both include confirmation modals with warnings + ## Troubleshooting **GPU not detected**: diff --git a/config/jupyterhub_config.py b/config/jupyterhub_config.py index e8c70e8..db93c00 100644 --- a/config/jupyterhub_config.py +++ b/config/jupyterhub_config.py @@ -146,7 +146,10 @@ c.JupyterHub.db_url = "sqlite:////data/jupyterhub.sqlite" # authenticate users with Native Authenticator # enable UI for native authenticator c.JupyterHub.authenticator_class = 'native' -c.JupyterHub.template_paths = [f"{os.path.dirname(nativeauthenticator.__file__)}/templates/"] +c.JupyterHub.template_paths = [ + "/srv/jupyterhub/templates/", # Custom templates + f"{os.path.dirname(nativeauthenticator.__file__)}/templates/" # NativeAuthenticator templates +] # allow anyone to sign-up without approval # allow all signed-up users to login @@ -158,4 +161,16 @@ c.Authenticator.allow_all = True c.Authenticator.admin_users = [JUPYTERHUB_ADMIN] c.JupyterHub.admin_access = True +# Custom API handlers for volume management and server control +import sys +sys.path.insert(0, '/start-platform.d') +sys.path.insert(0, '/') + +from custom_handlers import ResetHomeVolumeHandler, RestartServerHandler + +c.JupyterHub.extra_handlers = [ + (r'/api/users/([^/]+)/reset-home-volume', ResetHomeVolumeHandler), + (r'/api/users/([^/]+)/restart-server', RestartServerHandler), +] + # EOF diff --git a/services/jupyterhub/Dockerfile.jupyterhub b/services/jupyterhub/Dockerfile.jupyterhub index 1384756..b571701 100644 --- a/services/jupyterhub/Dockerfile.jupyterhub +++ b/services/jupyterhub/Dockerfile.jupyterhub @@ -34,6 +34,7 @@ EOF COPY --chmod=755 ./conf/bin/*.sh ./conf/bin/*.py / COPY --chmod=755 ./conf/bin/start-platform.d /start-platform.d COPY --chmod=600 ./templates/certs /mnt/certs +COPY --chmod=644 ./templates/*.html /srv/jupyterhub/templates/ ## install dockerspawner, nativeauthenticator RUN pip install -U --no-cache-dir \ diff --git a/services/jupyterhub/conf/bin/custom_handlers.py b/services/jupyterhub/conf/bin/custom_handlers.py new file mode 100755 index 0000000..2dcde4d --- /dev/null +++ b/services/jupyterhub/conf/bin/custom_handlers.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Custom JupyterHub API handlers for volume management and server control +""" + +from jupyterhub.handlers import BaseHandler +from jupyterhub.utils import admin_or_self +import docker + + +class ResetHomeVolumeHandler(BaseHandler): + """Handler for resetting user home volumes""" + + @admin_or_self + async def delete(self, username): + """ + Delete a user's home volume (only when server is stopped) + + DELETE /hub/api/users/{username}/reset-home-volume + """ + # 1. Verify user exists + user = self.find_user(username) + if not user: + self.log.warning(f"Reset volume request failed: user {username} not found") + return self.send_error(404, "User not found") + + # 2. Check server is stopped + spawner = user.spawner + if spawner.active: + self.log.warning(f"Reset volume request failed: {username}'s server is running") + return self.send_error(400, "Server must be stopped before resetting volume") + + # 3. Connect to Docker + try: + docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock') + except Exception as e: + self.log.error(f"Failed to connect to Docker: {e}") + return self.send_error(500, "Failed to connect to Docker daemon") + + # 4. Verify volume exists + volume_name = f'jupyterlab-{username}_home' + try: + volume = docker_client.volumes.get(volume_name) + except docker.errors.NotFound: + self.log.warning(f"Volume {volume_name} not found") + return self.send_error(404, f"Volume {volume_name} not found") + except Exception as e: + self.log.error(f"Error checking volume {volume_name}: {e}") + return self.send_error(500, f"Failed to check volume: {str(e)}") + + # 5. Remove volume + try: + volume.remove() + self.log.info(f"Successfully removed volume {volume_name} for user {username}") + self.set_status(200) + self.finish({"message": f"Volume {volume_name} successfully reset"}) + except docker.errors.APIError as e: + self.log.error(f"Failed to remove volume {volume_name}: {e}") + return self.send_error(500, f"Failed to remove volume: {str(e)}") + finally: + docker_client.close() + + +class RestartServerHandler(BaseHandler): + """Handler for restarting user servers""" + + @admin_or_self + async def post(self, username): + """ + Restart a user's server using Docker container restart + + POST /hub/api/users/{username}/restart-server + """ + # 1. Verify user exists + user = self.find_user(username) + if not user: + self.log.warning(f"Restart server request failed: user {username} not found") + return self.send_error(404, "User not found") + + # 2. Check server is running + spawner = user.spawner + if not spawner.active: + self.log.warning(f"Restart server request failed: {username}'s server is not running") + return self.send_error(400, "Server is not running") + + # 3. Get container name from spawner + container_name = f'jupyterlab-{username}' + + # 4. Connect to Docker and restart container + try: + docker_client = docker.DockerClient(base_url='unix://var/run/docker.sock') + except Exception as e: + self.log.error(f"Failed to connect to Docker: {e}") + return self.send_error(500, "Failed to connect to Docker daemon") + + try: + # Get the container + container = docker_client.containers.get(container_name) + + # Restart the container (graceful restart with 10s timeout) + self.log.info(f"Restarting container {container_name} for user {username}") + container.restart(timeout=10) + + self.log.info(f"Successfully restarted container {container_name}") + self.set_status(200) + self.finish({"message": f"Container {container_name} successfully restarted"}) + except docker.errors.NotFound: + self.log.warning(f"Container {container_name} not found") + return self.send_error(404, f"Container {container_name} not found") + except docker.errors.APIError as e: + self.log.error(f"Failed to restart container {container_name}: {e}") + return self.send_error(500, f"Failed to restart container: {str(e)}") + finally: + docker_client.close() diff --git a/services/jupyterhub/templates/home.html b/services/jupyterhub/templates/home.html new file mode 100644 index 0000000..a797fc9 --- /dev/null +++ b/services/jupyterhub/templates/home.html @@ -0,0 +1,207 @@ +{% extends "page.html" %} + +{% block main %} + +
Your Jupyter server is active and ready to use.
+ + Go to JupyterLab + + + + Stop Server + +