mirror of
https://github.com/stellarshenson/stellars-jupyterhub-ds.git
synced 2026-03-10 15:10:29 +00:00
feat: implement reset home volume and restart server features
- Add custom API handlers for volume reset and server restart
- Create custom home.html template with self-service buttons and modals
- Register handlers in jupyterhub_config.py with @admin_or_self permissions
- Update Dockerfile to copy templates and handlers
- Add custom templates path to JupyterHub configuration
- Update .claude/CLAUDE.md with feature documentation
- Reset Home Volume: DELETE /hub/api/users/{username}/reset-home-volume
- Restart Server: POST /hub/api/users/{username}/restart-server
- Both features use Docker API directly via /var/run/docker.sock
This commit is contained in:
@@ -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**:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
114
services/jupyterhub/conf/bin/custom_handlers.py
Executable file
114
services/jupyterhub/conf/bin/custom_handlers.py
Executable file
@@ -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()
|
||||
207
services/jupyterhub/templates/home.html
Normal file
207
services/jupyterhub/templates/home.html
Normal file
@@ -0,0 +1,207 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<div class="text-center">
|
||||
<h1>Welcome, {{ user.name }}!</h1>
|
||||
|
||||
{% if user.server %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Your Server is Running</h5>
|
||||
<p class="card-text">Your Jupyter server is active and ready to use.</p>
|
||||
<a href="/user/{{ user.name }}/lab" class="btn btn-lg btn-primary">
|
||||
<i class="fa fa-flask"></i> Go to JupyterLab
|
||||
</a>
|
||||
<button id="restart-server-btn"
|
||||
class="btn btn-lg btn-warning"
|
||||
data-username="{{ user.name }}"
|
||||
data-toggle="modal"
|
||||
data-target="#restart-server-modal">
|
||||
<i class="fa fa-refresh"></i> Restart Server
|
||||
</button>
|
||||
<a href="/hub/home" class="btn btn-lg btn-danger"
|
||||
data-method="delete"
|
||||
data-confirm="Are you sure you want to stop your server?">
|
||||
<i class="fa fa-stop"></i> Stop Server
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Your Server is Stopped</h5>
|
||||
<p class="card-text">Start your Jupyter server to begin working.</p>
|
||||
<a href="/hub/spawn" class="btn btn-lg btn-primary">
|
||||
<i class="fa fa-play"></i> Start Server
|
||||
</a>
|
||||
<button id="reset-home-volume-btn"
|
||||
class="btn btn-lg btn-danger"
|
||||
data-username="{{ user.name }}"
|
||||
data-toggle="modal"
|
||||
data-target="#reset-volume-modal">
|
||||
<i class="fa fa-trash"></i> Reset Home Volume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if user.admin %}
|
||||
<div class="card mt-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Admin Panel</h5>
|
||||
<p class="card-text">Manage users and servers.</p>
|
||||
<a href="/hub/admin" class="btn btn-lg btn-info">
|
||||
<i class="fa fa-dashboard"></i> Admin Panel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Home Volume Modal -->
|
||||
<div class="modal fade" id="reset-volume-modal" tabindex="-1" role="dialog" aria-labelledby="resetVolumeModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="resetVolumeModalLabel">Reset Home Volume</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-danger">
|
||||
<strong><i class="fa fa-exclamation-triangle"></i> Warning:</strong> This action cannot be undone!
|
||||
</div>
|
||||
<p>This will permanently delete all files in your home directory:</p>
|
||||
<code id="volume-name-display" class="d-block bg-light p-2 mb-3">jupyterlab-{{ user.name }}_home</code>
|
||||
<p class="mt-3">Your workspace and cache volumes will <strong>NOT</strong> be affected.</p>
|
||||
<p><strong>Are you sure you want to continue?</strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-danger" id="confirm-reset-btn">
|
||||
<i class="fa fa-check"></i> Yes, Reset Home Volume
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restart Server Modal -->
|
||||
<div class="modal fade" id="restart-server-modal" tabindex="-1" role="dialog" aria-labelledby="restartServerModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="restartServerModalLabel">Restart Server</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong><i class="fa fa-info-circle"></i> Notice:</strong> Your server will be temporarily unavailable during restart.
|
||||
</div>
|
||||
<p>This will restart your JupyterLab container using Docker's native restart:</p>
|
||||
<ul>
|
||||
<li>Gracefully stops the container</li>
|
||||
<li>Restarts the same container (does not recreate)</li>
|
||||
<li>Preserves all volumes and configuration</li>
|
||||
</ul>
|
||||
<p class="mt-3"><strong>Any unsaved work in notebooks will be lost.</strong></p>
|
||||
<p class="mt-2">Your files on disk are safe and will remain intact.</p>
|
||||
<p><strong>Are you sure you want to restart?</strong></p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-warning" id="confirm-restart-btn">
|
||||
<i class="fa fa-check"></i> Yes, Restart Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock main %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
const username = "{{ user.name }}";
|
||||
|
||||
// Reset Home Volume handler
|
||||
$('#confirm-reset-btn').on('click', function() {
|
||||
const apiUrl = `/hub/api/users/${username}/reset-home-volume`;
|
||||
const $btn = $(this);
|
||||
|
||||
// Disable button and show loading state
|
||||
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Resetting...');
|
||||
|
||||
$.ajax({
|
||||
url: apiUrl,
|
||||
type: 'DELETE',
|
||||
headers: {
|
||||
'X-XSRFToken': getCookie('_xsrf')
|
||||
},
|
||||
success: function(response) {
|
||||
$('#reset-volume-modal').modal('hide');
|
||||
alert('Home volume successfully reset. Your home directory will be recreated on next server start.');
|
||||
location.reload();
|
||||
},
|
||||
error: function(xhr) {
|
||||
$('#reset-volume-modal').modal('hide');
|
||||
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to reset volume';
|
||||
alert(`Error: ${errorMsg}`);
|
||||
$btn.prop('disabled', false).html('<i class="fa fa-check"></i> Yes, Reset Home Volume');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Restart Server handler
|
||||
$('#confirm-restart-btn').on('click', function() {
|
||||
const apiUrl = `/hub/api/users/${username}/restart-server`;
|
||||
const $btn = $(this);
|
||||
|
||||
// Disable button and show loading state
|
||||
$btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Restarting...');
|
||||
|
||||
$.ajax({
|
||||
url: apiUrl,
|
||||
type: 'POST',
|
||||
headers: {
|
||||
'X-XSRFToken': getCookie('_xsrf')
|
||||
},
|
||||
success: function(response) {
|
||||
$('#restart-server-modal').modal('hide');
|
||||
alert('Server successfully restarted. Redirecting to your server...');
|
||||
// Wait a moment for restart to complete, then redirect
|
||||
setTimeout(function() {
|
||||
window.location.href = `/user/${username}/lab`;
|
||||
}, 2000);
|
||||
},
|
||||
error: function(xhr) {
|
||||
$('#restart-server-modal').modal('hide');
|
||||
const errorMsg = xhr.responseJSON?.message || xhr.statusText || 'Failed to restart server';
|
||||
alert(`Error: ${errorMsg}`);
|
||||
$btn.prop('disabled', false).html('<i class="fa fa-check"></i> Yes, Restart Server');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to get cookie value
|
||||
function getCookie(name) {
|
||||
const value = `; ${document.cookie}`;
|
||||
const parts = value.split(`; ${name}=`);
|
||||
if (parts.length === 2) return parts.pop().split(';').shift();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock script %}
|
||||
Reference in New Issue
Block a user