Skip to content

Instantly share code, notes, and snippets.

@pdmack
Last active April 22, 2021 05:21
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pdmack/9d5e88bdb968330ed0bdb1903682fa9e to your computer and use it in GitHub Desktop.
Save pdmack/9d5e88bdb968330ed0bdb1903682fa9e to your computer and use it in GitHub Desktop.
OpenShift template for deploying Kubeflow (0.4.1) JupyterHub; launches jupyterhub/singleuser:latest image for unknown reason
# CREATE
# oc create -f <this raw gist> -n openshift
# oc new-project jupyterhub
# oc adm policy add-scc-to-user anyuid -z jupyter
# oc process openshift-jupyterhub -n openshift | oc create -f -
# CLEAN UP
# oc delete all --all
# oc delete sa jupyter
# oc delete cm jupyter-config
# oc delete rolebinding jupyter-role
# oc delete role jupyter-role
# oc delete -f jupyterhub-template.yaml -n openshift
apiVersion: template.openshift.io/v1
kind: Template
metadata:
name: openshift-jupyterhub
annotations:
description: Demo template for deploying JupyterHub on OpenShift.
openshift.io/display-name: OpenShift JupyterHub
iconClass: "icon-python"
tags: ml,ai,jupyter,notebook,ipython
objects:
- apiVersion: v1
data:
jupyter_config.py: |
"""Configuration file for JupyterHub.
Kubeflow uses this file as the configuration file for JupyterHub. It contains
all glue code necessary to integrate JupyterHub with the remaining Kubeflow
components.
Note that this file is also responsible for importing the UI-specific Spawner
class from <ui-dir>/spawner.py, and setting the `spawner_class` configuration
option.
"""
import os
from importlib.util import spec_from_file_location, module_from_spec
from jhub_remote_user_authenticator.remote_user_auth import \
RemoteUserAuthenticator
SERVICE_ACCOUNT_SECRET_MOUNT = '/var/run/secrets/sa'
# Import the UI-specific Spawner
spec = spec_from_file_location('spawner', '/etc/config/spawner.py')
spawner = module_from_spec(spec)
spec.loader.exec_module(spawner)
###################################################
# JupyterHub Options
###################################################
c.JupyterHub.ip = '0.0.0.0'
c.JupyterHub.hub_ip = '0.0.0.0'
# Don't try to cleanup servers on exit - since in general for k8s, we want
# the hub to be able to restart without losing user containers
c.JupyterHub.cleanup_servers = False
###################################################
###################################################
# Spawner Options
###################################################
c.JupyterHub.spawner_class = spawner.KubeFormSpawner
c.KubeSpawner.cmd = 'start-singleuser.sh'
c.KubeSpawner.args = ['--allow-root']
# gpu images are very large ~15GB. need a large timeout.
c.KubeSpawner.start_timeout = 60 * 30
# Increase timeout to 5 minutes to avoid HTTP 500 errors on JupyterHub
c.KubeSpawner.http_timeout = 60 * 5
# Volume setup
c.KubeSpawner.singleuser_uid = 1000
c.KubeSpawner.singleuser_fs_gid = 100
c.KubeSpawner.singleuser_working_dir = '/home/jovyan'
c.KubeSpawner.environment = {'HOME':'/home/jovyan'}
# https://github.com/openshift-labs/lab-developing-with-odo/pull/2
c.KubeSpawner.events_enabled = False
# Allow environment vars to override uid and gid.
# This allows local host path mounts to be read/writable
env_uid = os.environ.get('NOTEBOOK_UID')
if env_uid:
c.KubeSpawner.singleuser_uid = int(env_uid)
env_gid = os.environ.get('NOTEBOOK_GID')
if env_gid:
c.KubeSpawner.singleuser_fs_gid = int(env_gid)
access_local_fs = os.environ.get('ACCESS_LOCAL_FS')
if access_local_fs == 'true':
def modify_pod_hook(spawner, pod):
pod.spec.containers[0].lifecycle = {
'postStart': {
'exec': {
'command': [
'ln', '-s', '/mnt/local-notebooks',
'/home/jovyan/local-notebooks'
]
}
}
}
return pod
c.KubeSpawner.modify_pod_hook = modify_pod_hook
###################################################
# Persistent volume options
###################################################
# Set user_storage_pvc_ensure to False to prevent KubeSpawner from handling PVCs
# We natively handle PVCs via KubeFormSpawner and its dedicated methods
# NOTE: user_storage_pvc_ensure has been deprecated in a future release
c.KubeSpawner.storage_pvc_ensure = False
c.KubeSpawner.user_storage_pvc_ensure = False
volumes = []
volume_mounts = []
gcp_secret_name = os.environ.get('GCP_SECRET_NAME')
if gcp_secret_name:
volumes.append({
'name': gcp_secret_name,
'secret': {
'secretName': gcp_secret_name,
}
})
volume_mounts.append({
'name': gcp_secret_name,
'mountPath': SERVICE_ACCOUNT_SECRET_MOUNT
})
c.KubeSpawner.volumes = volumes
c.KubeSpawner.volume_mounts = volume_mounts
storage_class = None
if os.environ.get('STORAGE_CLASS') != 'null':
storage_class = os.environ.get('STORAGE_CLASS')
rok_secret_name = ''
if os.environ.get('ROK_SECRET_NAME') != 'null':
rok_secret_name = os.environ.get('ROK_SECRET_NAME')
# Set both service_account and singleuser_service_account because
# singleuser_service_account has been deprecated in a future release
c.KubeSpawner.service_account = 'jupyter'
c.KubeSpawner.singleuser_service_account = 'jupyter'
# Authenticator
if os.environ.get('KF_AUTHENTICATOR') == 'iap':
c.JupyterHub.authenticator_class = RemoteUserAuthenticator
c.RemoteUserAuthenticator.header_name = 'x-goog-authenticated-user-email'
else:
c.JupyterHub.authenticator_class = 'dummyauthenticator.DummyAuthenticator'
if os.environ.get('DEFAULT_JUPYTERLAB').lower() == 'true':
c.KubeSpawner.default_url = '/lab'
# Set extra spawner configuration variables
c.KubeSpawner.extra_spawner_config = {
'gcp_secret_name': gcp_secret_name,
'storage_class': storage_class,
'rok_secret_name': rok_secret_name,
}
script.js: |
// This function is executed when the document is ready
$(function() {
// Toggle advanced options inside the Spawner form
$('#toggle_advanced_options').on('click', function(e) {
$('#advanced_fields').toggle();
});
// Resize Spawner form to take up more page width
$('.row.col-sm-offset-2.col-sm-8').attr({
'class': 'row col-sm-offset-1 col-sm-10',
'style': 'padding: 15px;'
});
// Update upper-right sign-out icon to FontAwesome 5
$('.fa.fa-sign-out').attr('class', 'fas fa-sign-out-alt');
// Update Spawn button text upon form submission
if (formDefaults) {
$('#spawn_form').one('submit', function() {
$(this).find('input[type="submit"]')
.attr('disabled', true)
.val('Spawning...');
});
} else {
$("h1:contains('Spawner Options')" ).remove();
$('#spawn_form').find('input[type="submit"]').remove();
}
// Configure Image input elements
setImageType();
// Dynamically change Workspace form fields behavior
setWorkspaceEventListeners();
// Fill the form with values defined in the YAML config file
setDefaultFormValues();
// Set tooltip to readOnly form fields
setTooltipsOnImmutable();
});
// Dynamically update Image input field, based on radio button selection
function setImageType() {
imageType = $('#imageType').find('input:checked').val();
if (imageType == 'standard') {
$('select[for=standardImages]')
.attr({'id': 'image', 'name': 'image'}).css({'display': ''});
$('input[for=customImage]')
.attr({'id': '', 'name': ''}).removeAttr('required').css({'display': 'none'});
} else {
$('input[for=customImage]')
.attr({'id': 'image', 'name': 'image'}).css({'display': ''});
$('select[for=standardImages]')
.attr({'id': '', 'name': ''}).removeAttr('required').css({'display': 'none'});
}
}
// Set default values to form fields
function setDefaultFormValues() {
// If config.yaml is empty, no need to initialize anything
if (!formDefaults) {
return;
}
if ('image' in formDefaults) {
// Set Container image dropdown list
if ('options' in formDefaults.image) {
formDefaults.image.options.forEach(function(item) {
$('#image').append($('<option/>').attr('value', item).text(item));
});
}
// Set default Container Image, if specified
$('#image').val('');
if ('value' in formDefaults.image) {
$('#image').val(formDefaults.image.value);
}
// Make Container Image field readonly, if specified
if ('readOnly' in formDefaults.image) {
$('#option_standard').prop({
'disabled': formDefaults.image.readOnly,
'immutable': formDefaults.image.readOnly
});
$('#option_custom').prop({
'disabled': formDefaults.image.readOnly,
'immutable': formDefaults.image.readOnly
});
}
}
if ('cpu' in formDefaults) {
// Set default CPU, if specified
$('#cpu').val('');
if ('value' in formDefaults.cpu) {
$('#cpu').val(formDefaults.cpu.value);
}
// Make CPU field readonly, if specified
if ('readOnly' in formDefaults.cpu) {
$('#cpu').attr({
'readonly': formDefaults.cpu.readOnly,
'immutable': formDefaults.cpu.readOnly
});
}
}
if ('memory' in formDefaults) {
// Set default Memory, if specified
$('#memory').val('');
if ('value' in formDefaults.memory) {
$('#memory').val(formDefaults.memory.value);
}
// Make Memory field readonly if specified
if ('readOnly' in formDefaults.memory) {
$('#memory').attr({
'readonly': formDefaults.memory.readOnly,
'immutable': formDefaults.memory.readOnly
});
}
}
$('#ws_name').attr('placeholder', username + '-workspace');
$('#ws_mount_path').attr('placeholder', '/home/jovyan');
if ('workspaceVolume' in formDefaults) {
var defaultWorkspaceReadOnly = formDefaults.workspaceVolume.readOnly
if ('value' in formDefaults.workspaceVolume) {
var defaultWorkspace = formDefaults.workspaceVolume.value;
// Set the default Workspace Volume, if specified
if (defaultWorkspace) {
if ('type' in defaultWorkspace) {
// Set the Workspace Volume Type, if specified
$('#ws_type').val('');
if ('value' in defaultWorkspace.type) {
$('#ws_type').val(defaultWorkspace.type.value);
}
// Make the Workspace Volume Type readonly, if specified
if ('readOnly' in defaultWorkspace.type || 'readOnly' in formDefaults.workspaceVolume) {
$('#ws_type').attr({
'readonly': defaultWorkspace.type.readOnly || defaultWorkspaceReadOnly,
'immutable': defaultWorkspace.type.readOnly || defaultWorkspaceReadOnly
});
if ($('#ws_type').attr('readonly')) {
$('#ws_type').on('mousedown', function(e) {
e.preventDefault(); this.blur(); window.focus();
});
}
}
}
$('#ws_type').trigger('change');
if ('name' in defaultWorkspace) {
$('#ws_name').val('');
// Set the Workspace Volume Name, if specified
if ('value' in defaultWorkspace.name) {
$('#ws_name').val(defaultWorkspace.name.value).trigger('focusout');
}
// Make the Workspace Volume Name readonly, if specified
if ('readOnly' in defaultWorkspace.name || 'readOnly' in formDefaults.workspaceVolume) {
$('#ws_name').attr({
'readonly': defaultWorkspace.name.readOnly || defaultWorkspaceReadOnly,
'immutable': defaultWorkspace.name.readOnly || defaultWorkspaceReadOnly
});
}
}
if ('size' in defaultWorkspace) {
$('#ws_size').val('');
// Set the Workspace Volume Size, if specified
if ('value' in defaultWorkspace.size) {
$('#ws_size').val(defaultWorkspace.size.value);
}
// Make the Workspace Volume Size readonly, if specified
if ('readOnly' in defaultWorkspace.size || 'readOnly' in formDefaults.workspaceVolume) {
$('#ws_size').attr({
'readonly': defaultWorkspace.size.readOnly || defaultWorkspaceReadOnly,
'immutable': defaultWorkspace.size.readOnly || defaultWorkspaceReadOnly
});
}
}
if ('mountPath' in defaultWorkspace) {
$('#ws_mount_path').val('');
// Set the Workspace Volume MountPath, if specified
if ('value' in defaultWorkspace.mountPath) {
$('#ws_mount_path').val(defaultWorkspace.mountPath.value);
}
// Make the Workspace Volume MountPath readonly, if specified
if ('readOnly' in defaultWorkspace.mountPath || 'readOnly' in formDefaults.workspaceVolume) {
$('#ws_mount_path').attr({
'readonly': defaultWorkspace.mountPath.readOnly || defaultWorkspaceReadOnly,
'immutable': defaultWorkspace.mountPath.readOnly || defaultWorkspaceReadOnly
});
}
}
if ('accessModes' in defaultWorkspace) {
$('#ws_access_modes').val('');
// Set the Workspace Volume Access Modes, if specified
if ('value' in defaultWorkspace.accessModes) {
$('#ws_access_modes').val(defaultWorkspace.accessModes.value);
}
// Make the Workspace Volume Access Modes readonly, if specified
if ('readOnly' in defaultWorkspace.accessModes || 'readOnly' in formDefaults.workspaceVolume) {
$('#ws_access_modes').attr({
'readonly': defaultWorkspace.accessModes.readOnly || defaultWorkspaceReadOnly,
'immutable': defaultWorkspace.accessModes.readOnly || defaultWorkspaceReadOnly
});
if ($('#ws_access_modes').attr('readonly')) {
$('#ws_access_modes').on('mousedown', function(e) {
e.preventDefault(); this.blur(); window.focus();
});
}
}
}
}
}
}
if ('dataVolumes' in formDefaults) {
var dataVolumesReadOnly = formDefaults.dataVolumes.readOnly
// Disable Add Volume button, if specified
if ('readOnly' in formDefaults.dataVolumes) {
$('#add_volume').attr({
'disabled': dataVolumesReadOnly,
'immutable': dataVolumesReadOnly
});
}
// Set default Data Volumes - Disable if specified
var defaultDataVolumes = []
if ('value' in formDefaults.dataVolumes) {
defaultDataVolumes = formDefaults.dataVolumes.value;
}
for (i = 0; i < defaultDataVolumes.length; i++) {
addVolume();
var vol = {}
if ('value' in defaultDataVolumes[i]) {
vol = defaultDataVolumes[i].value;
}
if ('type' in vol) {
$('#vol_type' + counter).val('');
if ('value' in vol.type) {
$('#vol_type' + counter).val(vol.type.value).trigger('change');
}
if ('readOnly' in vol.type || 'readOnly' in formDefaults.dataVolumes) {
$('#vol_type' + counter).attr({
'readonly': vol.type.readOnly || dataVolumesReadOnly,
'immutable': vol.type.readOnly || dataVolumesReadOnly
});
if ($('#vol_type' + counter).attr('readonly')) {
$('#vol_type' + counter).on('mousedown', function(e) {
e.preventDefault(); this.blur(); window.focus();
});
}
}
}
if ('name' in vol) {
$('#vol_name' + counter).val('');
if ('value' in vol.name) {
$('#vol_name' + counter).val(vol.name.value).trigger('focusout');
}
if ('readOnly' in vol.name || 'readOnly' in formDefaults.dataVolumes) {
$('#vol_name' + counter).attr({
'readonly': vol.name.readOnly || dataVolumesReadOnly,
'immutable': vol.name.readOnly || dataVolumesReadOnly
});
}
}
if ('size' in vol) {
$('#vol_size' + counter).val('');
if ('value' in vol.size) {
$('#vol_size' + counter).val(vol.size.value);
}
if ('readOnly' in vol.size || 'readOnly' in formDefaults.dataVolumes) {
$('#vol_size' + counter).attr({
'readonly': vol.size.readOnly || dataVolumesReadOnly,
'immutable': vol.size.readOnly || dataVolumesReadOnly
});
}
}
if ('mountPath' in vol) {
$('#vol_mount_path' + counter).val('');
if ('value' in vol.mountPath) {
$('#vol_mount_path' + counter).val(vol.mountPath.value);
}
if ('readOnly' in vol.mountPath || 'readOnly' in formDefaults.dataVolumes) {
$('#vol_mount_path' + counter).attr({
'readonly': vol.mountPath.readOnly || dataVolumesReadOnly,
'immutable': vol.mountPath.readOnly || dataVolumesReadOnly
});
}
}
if ('accessModes' in vol) {
$('#vol_access_modes' + counter).val('');
if ('value' in vol.accessModes) {
$('#vol_access_modes' + counter).val(vol.accessModes.value);
}
if ('readOnly' in vol.accessModes || 'readOnly' in formDefaults.dataVolumes) {
$('#vol_access_modes' + counter).attr({
'readonly': vol.accessModes.readOnly || dataVolumesReadOnly,
'immutable': vol.accessModes.readOnly || dataVolumesReadOnly
});
if ($('#vol_access_modes' + counter).attr('readonly')) {
$('#vol_access_modes' + counter).on('mousedown', function(e) {
e.preventDefault(); this.blur(); window.focus();
});
}
}
}
// Disable Delete button, if specified
if ('readOnly' in formDefaults.dataVolumes) {
$('#vol_delete_button' + counter).attr({
'disabled': formDefaults.dataVolumes.readOnly,
'immutable': formDefaults.dataVolumes.readOnly
});
}
}
}
if ('extraResources' in formDefaults) {
// Set default Extra Resources, if specified
$('#extraResources').val('{}');
if ('value' in formDefaults.extraResources) {
$('#extraResources').val(formDefaults.extraResources.value);
}
// Make Extra Resources field readonly, if specified
if ('readOnly' in formDefaults.extraResources) {
$('#extraResources').attr({
'readonly': formDefaults.extraResources.readOnly,
'immutable': formDefaults.extraResources.readOnly
});
}
}
}
// Register jQuery event listeners for the Workspace Volume
function setWorkspaceEventListeners() {
var workspaceType = $('#ws_type');
var workspaceName = $('#ws_name');
var workspaceSize = $('#ws_size');
var workspaceAccessModes = $('#ws_access_modes');
var workspaceMountPath = $('#ws_mount_path');
// Disable/Enable Workspace size option based on its Type
workspaceType.on('change', function() {
// Set attributes for the Volume fields
if (this.value == 'Existing') {
setAttributes(workspaceName, {'list': 'suggest_pvcs'});
setAttributes(workspaceSize, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Size is autofilled when mounting existing Volumes'
});
setAttributes(workspaceAccessModes, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Access Mode is autofilled when mounting existing Volumes'
});
$('#ws_access_modes option').not(':selected').attr('disabled', 'disabled')
} else if (this.value == 'New') {
setAttributes(workspaceName, {'list': 'suggest_pvcs_disabled'});
unsetAttributes(workspaceSize, 'readonly data-toggle data-placement title');
unsetAttributes(workspaceAccessModes, 'readonly data-toggle data-placement title');
}
// Set values for non-readonly Volume fields
setValue(workspaceName, '');
setValue(workspaceSize, workspaceSize.attr('placeholder'));
setValue(workspaceMountPath, '');
setValue(workspaceAccessModes, workspaceAccessModes.find('option:first').val());
});
workspaceName.on('focusout', function() {
for (var i = 0; i < existingPVCs.length; i++) {
if (existingPVCs[i].name == this.value) {
// Volume already exists - autocomplete its Size and Access Mode
setValue(workspaceType, 'Existing');
setAttributes(workspaceName, {'list': 'suggest_pvcs'});
setAttributes(workspaceSize, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Size is autofilled when mounting existing Volumes'
});
setValue(workspaceSize, existingPVCs[i].size);
setAttributes(workspaceAccessModes, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Access Mode is autofilled when mounting existing Volumes'
});
setValue(workspaceAccessModes, existingPVCs[i].access_modes);
break;
}
}
if (this.value.length > 0) {
setValue(workspaceMountPath, '/home/jovyan/' + this.value);
} else {
setValue(workspaceMountPath, '' + this.value);
}
if (i == existingPVCs.length) {
// Volume does not exist - set its Type to 'New'
setValue(workspaceType, 'New');
setAttributes(workspaceName, {'list': 'suggest_pvcs_disabled'});
unsetAttributes(workspaceSize, 'readonly data-toggle data-placement title');
setValue(workspaceSize, '');
unsetAttributes(workspaceAccessModes, 'readonly data-toggle data-placement title');
setValue(workspaceAccessModes, workspaceAccessModes.find('option:first').val());
}
});
// Trigger focusout event to check the Workspace name
workspaceName.trigger('focusout');
}
// Counter and options for Dataset Volumes
var counter = 0;
var options = [
'vol_type', 'vol_name', 'vol_size', 'vol_mount_path', 'vol_access_modes'
];
// Dynamically adds a UI element for configuring a volume
function addVolume() {
counter++;
// Input for volume type
var volumeType = $('<select>').attr({
class: 'form-control',
id: 'vol_type' + counter,
name: 'vol_type' + counter,
required: true
});
volumeType
.append($('<option/>').attr({selected: true, value: 'New'}).text('New'))
.append($('<option/>').attr({value: 'Existing'}).text('Existing'));
// Input for volume name
var volumeName = $('<input>').attr({
class: 'form-control',
id: 'vol_name' + counter,
name: 'vol_name' + counter,
type: 'text',
placeholder: username + '-volume-' + counter,
list: 'suggest_pvcs_disabled',
value: username + '-volume-' + counter,
required: true
});
// Input for volume size
var volumeSize = $('<input>').attr({
class: 'form-control',
id: 'vol_size' + counter,
name: 'vol_size' + counter,
type: 'number',
min: '0',
step: '0.5',
placeholder: '10',
value: '10',
required: true
});
// Input for volume mount point
var volumeMountPath = $('<input>').attr({
class: 'form-control',
id: 'vol_mount_path' + counter,
name: 'vol_mount_path' + counter,
type: 'text',
placeholder: '/home/jovyan/' + username + '-volume-' + counter,
required: true
});
// Selection for volume access mode
var volumeAccessModes = $('<select>').attr({
class: 'form-control',
id: 'vol_access_modes' + counter,
name: 'vol_access_modes' + counter,
required: true
});
volumeAccessModes
.append($('<option/>').attr({value: 'ReadWriteOnce'}).text('ReadWriteOnce'))
.append($('<option/>').attr({value: 'ReadWriteMany'}).text('ReadWriteMany'))
.append($('<option/>').attr({value: 'ReadOnlyMany'}).text('ReadOnlyMany'));
// Delete button for volume removal
var deleteButton = $('<button/>').attr({
class: 'btn btn-danger btn-sm',
id: 'vol_delete_button' + counter,
type: 'button',
onclick: 'removeVolume(' + counter + ');'
});
deleteButton.append($('<i>').attr({class: 'fas fa-minus'}));
// Disable/Enable Volume size option based on its Type
volumeType.on('change', function() {
if (this.value == 'Existing') {
// Set attributes for the Volume fields
setAttributes(volumeName, {'list': 'suggest_pvcs'});
setAttributes(volumeSize, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Size is autofilled when mounting existing Volumes'
});
setAttributes(volumeAccessModes, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Access Mode is autofilled when mounting existing Volumes'
});
$('#vol_access_modes option').not(':selected').attr('disabled', 'disabled')
} else if (this.value == 'New') {
setAttributes(volumeName, {'list': 'suggest_pvcs_disabled'});
unsetAttributes(volumeSize, 'readonly data-toggle data-placement title');
unsetAttributes(volumeAccessModes, 'readonly data-toggle data-placement title');
}
// Set values for non-readonly Volume fields
setValue(volumeName, '');
setValue(volumeSize, volumeSize.attr('placeholder'));
setValue(volumeMountPath, '');
setValue(volumeAccessModes, volumeAccessModes.find('option:first').val());
});
volumeName.on('focusout', function() {
for (var i = 0; i < existingPVCs.length; i++) {
if (existingPVCs[i].name == this.value) {
// Volume already exists - autocomplete its Size and Access Mode
setValue(volumeType, 'Existing');
setAttributes(volumeName, {'list': 'suggest_pvcs'});
setAttributes(volumeSize, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Size is autofilled when mounting existing Volumes'
});
setValue(volumeSize, existingPVCs[i].size)
setAttributes(volumeAccessModes, {
'readonly': true,
'data-toggle': 'tooltip', 'data-placement': 'top',
'title': 'Access Mode is autofilled when cloning existing Volumes'
});
setValue(volumeAccessModes, existingPVCs[i].access_modes)
break;
}
}
if (this.value.length > 0) {
setValue(volumeMountPath, '/home/jovyan/' + this.value);
} else {
setValue(volumeMountPath, '' + this.value);
}
if (i == existingPVCs.length) {
// Volume does not exist - set its Type to 'New'
setValue(volumeType, 'New');
setAttributes(volumeName, {'list': 'suggest_pvcs_disabled'});
unsetAttributes(volumeSize, 'readonly data-toggle data-placement title');
unsetAttributes(volumeAccessModes, 'readonly data-toggle data-placement title');
setValue(volumeAccessModes, volumeAccessModes.find('option:first').val());
}
});
// Create and append new volume
$('<div/>', {'class': 'form-group volume' + counter})
.append($('<div/>', {class: 'col-sm-2 form-group', style: 'width: 14%'}).append(volumeType))
.append($('<div/>', {class: 'col-sm-3 form-group', style: 'width: 20%'}).append(volumeName))
.append($('<div/>', {class: 'col-sm-2 form-group', style: 'width: 12%'}).append(volumeSize))
.append($('<div/>', {class: 'col-sm-3 form-group', style: 'width: 29%'}).append(volumeMountPath))
.append($('<div/>', {class: 'col-sm-3 form-group', style: 'width: 19%'}).append(volumeAccessModes))
.append($('<div/>', {class: 'col-sm-1 form-group', style: 'width: 5%; padding: 2px'}).append(deleteButton))
.hide().fadeIn('fast').appendTo($('#data_volumes'));
// Trigger focusout event to check Volume Name
volumeName.trigger('focusout');
}
// Dynamically remove a previously added UI element for configuring a volume
function removeVolume(id) {
$('.volume' + id).fadeOut('fast', function() {
$(this).remove();
});
counter--;
// Normalize the IDs of remainder volumes
for (i = id; i <= counter; i++) {
var volumeElement = $('.volume' + (i + 1));
volumeElement.find('#vol_delete_button' + (i + 1)).attr({
'id': 'vol_delete_button' + i,
'onclick': 'removeVolume(' + i + ')'
});
// Update the class of the Volume
volumeElement.removeClass('volume' + (i + 1)).addClass('volume' + i);
// Update the id and name of the Volume options
options.forEach(function(option) {
volumeElement.find('#' + option + (i + 1)).attr({
'id': option + i,
'name': option + i
});
});
// Update Volume options
setAttributes(volumeElement.find('[id^=vol_name]'), {
'placeholder': username + '-volume-' + i,
});
setValue(volumeElement.find('[id^=vol_name]'), username + '-volume-' + i)
setAttributes(volumeElement.find('[id^=vol_mount_path]'), {
'placeholder': '/home/jovyan/' + username + '-volume-' + i,
});
setValue(volumeElement.find('[id^=vol_mount_path]'), '/home/jovyan/' + username + '-volume-' + i)
}
}
// Helper function to set a tooltip to admin-disabled Spawner form fields
function setTooltipsOnImmutable() {
$(':input[immutable=true]').attr({
'data-toggle': 'tooltip',
'data-placement': 'top',
'title': 'This option has been set by your administrator'
});
}
// Helper function to set the value of a mutable Spawner option
function setValue(element, value) {
if (!element.attr('immutable')) {
element.val(value);
}
}
// Helper function to set attributes of a mutable Spawner option
function setAttributes(element, attributes) {
if (!element.attr('immutable')) {
element.attr(attributes);
}
}
// Helper function to unset attributes of a mutable Spawner option
function unsetAttributes(element, attributes) {
if (!element.attr('immutable')) {
element.removeAttr(attributes);
}
}
spawner.py: |
import json
import yaml
import string
import escapism
from tornado import gen
from traitlets import Dict
from jinja2 import FileSystemLoader, Environment
from kubespawner.objects import make_pvc
from kubespawner.spawner import KubeSpawner
from kubernetes.client.rest import ApiException
SERVICE_ACCOUNT_SECRET_MOUNT = '/var/run/secrets/sa'
class KubeFormSpawner(KubeSpawner):
"""Implement a custom Spawner to spawn pods in a Kubernetes Cluster."""
def __init__(self, *args, **kwargs):
"""Call init() of parent class and initialize volume lists."""
super(KubeFormSpawner, self).__init__(*args, **kwargs)
self.initial_volumes = list(self.volumes)
self.initial_volume_mounts = list(self.volume_mounts)
@property
def spawner_ui_config(self):
# Read raw YAML file, format it and parse it as dict
if not hasattr(self, "_spawner_ui_config"):
c = None
try:
with open('/etc/config/spawner_ui_config.yaml', 'r') as f:
c = self._expand_user_properties(f.read())
except IOError:
self.log.warning('Error opening Spawner UI config file')
try:
if yaml.safe_load(c) is None:
# YAML exists but is empty
self._spawner_ui_config = {}
else:
# YAML exists and is not empty
self._spawner_ui_config = yaml.safe_load(c)
except yaml.YAMLError as e:
self.log.warning('Spawner UI config file contains'
'invalid YAML syntax: {}', e)
return None
return self._spawner_ui_config
extra_spawner_config = Dict(
{},
config=True,
help="""
A dictionary with extra configuration parameters for KubeFormSpawner.
"""
)
def options_form(self, form):
# Create Jinja environment to dynamically load templates
j2_env = Environment(loader=FileSystemLoader('/etc/config'))
# Get available PVCs in a given namespace
# This is a blocking K8s API call
existing_pvcs = self._list_pvcs_in_namespace(self.namespace)
form_defaults = None
if self.spawner_ui_config is not None:
# YAML exists and was parsed successfully
if self.spawner_ui_config['spawnerFormDefaults'] is not None:
form_defaults = self.spawner_ui_config['spawnerFormDefaults']
else:
form_defaults = {}
# Return the rendered template as a unicode string
return j2_env.get_template('template.html').render(
form_defaults=form_defaults,
existing_pvcs=existing_pvcs,
username=self._expand_user_properties('{username}')
)
def options_from_form(self, formdata):
options = {}
if self.spawner_ui_config is not None:
form_defaults = self.spawner_ui_config['spawnerFormDefaults']
# Manage Image
image_readonly = False
if self._default_config_contains('image'):
options['image'] = form_defaults['image']['value']
image_readonly = form_defaults['image'].get('readOnly', False)
if ('image' in formdata and formdata['image'][0]):
image_from_form = formdata['image'][0].strip()
if image_readonly:
# Provided image must be standard
if image_from_form in form_defaults['image']['options']:
options['image'] = image_from_form
else:
# Provided image can be standard or custom
options['image'] = image_from_form
# Manage CPU
cpu_readonly = False
if self._default_config_contains('cpu'):
options['cpu'] = form_defaults['cpu']['value']
cpu_readonly = form_defaults['cpu'].get('readOnly', False)
if (not cpu_readonly and 'cpu' in formdata and formdata['cpu'][0]):
options['cpu'] = formdata['cpu'][0].strip()
# Manage Memory
memory_readonly = False
if self._default_config_contains('memory'):
options['memory'] = form_defaults['memory']['value']
memory_readonly = form_defaults['memory'].get('readOnly', False)
if (not memory_readonly and 'memory' in formdata and
formdata['memory'][0]):
options['memory'] = formdata['memory'][0].strip()
# Manage Workspace Volume
options['workspaceVolume'] = {}
ws_volume = {}
ws_volume_readonly = False
if self._default_config_contains('workspaceVolume'):
ws_volume_readonly = \
form_defaults['workspaceVolume'].get('readOnly', False)
# The Workspace Volume is specified in `config.yaml`
default_ws_volume = form_defaults['workspaceVolume']['value']
# Get and set the default values from the YAML configuration file,
# if present and not marked as readonly
ws_type_readonly = False
if ('type' in default_ws_volume and
'value' in default_ws_volume['type']):
ws_volume['type'] = default_ws_volume['type']['value']
ws_type_readonly = \
default_ws_volume['type'].get('readOnly', False)
ws_name_readonly = False
if ('name' in default_ws_volume and
'value' in default_ws_volume['name']):
ws_volume['name'] = default_ws_volume['name']['value']
ws_name_readonly = \
default_ws_volume['name'].get('readOnly', False)
ws_size_readonly = False
if ('size' in default_ws_volume and
'value' in default_ws_volume['size']):
ws_volume['size'] = \
'%sGi' % default_ws_volume['size']['value']
ws_size_readonly = \
default_ws_volume['size'].get('readOnly', False)
ws_mount_path_readonly = False
if ('mountPath' in default_ws_volume and
'value' in default_ws_volume['mountPath']):
ws_volume['mountPath'] = \
default_ws_volume['mountPath']['value']
ws_mount_path_readonly = \
default_ws_volume['mountPath'].get('readOnly', False)
ws_access_modes_readonly = False
if ('accessModes' in default_ws_volume and
'value' in default_ws_volume['accessModes']):
ws_volume['accessModes'] = \
default_ws_volume['accessModes']['value']
ws_access_modes_readonly = \
default_ws_volume['accessModes'].get('readOnly', False)
# Get and set the Workspace Volume values from the form, if present
# and not marked as readonly
if not ws_volume_readonly:
if (not ws_type_readonly and 'ws_type' in formdata and
formdata['ws_type'][0]):
ws_volume['type'] = formdata['ws_type'][0].strip()
if (not ws_name_readonly and 'ws_name' in formdata and
formdata['ws_name'][0]):
ws_volume['name'] = formdata['ws_name'][0].strip()
if (not ws_size_readonly and 'ws_size' in formdata and
formdata['ws_size'][0]):
ws_volume['size'] = '%sGi' % formdata['ws_size'][0].strip()
if (not ws_mount_path_readonly and 'ws_mount_path' in formdata and
formdata['ws_mount_path'][0]):
ws_volume['mountPath'] = \
formdata['ws_mount_path'][0].strip()
if (not ws_access_modes_readonly and 'ws_access_modes' in formdata
and formdata['ws_access_modes'][0]):
ws_volume['accessModes'] = \
formdata['ws_access_modes'][0].strip()
options['workspaceVolume'] = ws_volume
# Manage Data Volumes
options['dataVolumes'] = []
data_volumes_readonly = False
if self._default_config_contains('dataVolumes'):
data_volumes_readonly = \
form_defaults['dataVolumes'].get('readOnly', False)
if data_volumes_readonly:
# Set Data Volumes as specified in the Spawner configuration file
for volume in form_defaults['dataVolumes']['value']:
data_volume = {}
for f in ['type', 'name', 'size', 'mountPath', 'accessModes']:
data_volume[f] = volume['value'][f]['value']
data_volume['size'] += 'Gi'
options['dataVolumes'].append(data_volume)
else:
# Deduce the total number of Data Volumes
data_volumes_cnt = 0
for k, v in formdata.items():
if k.startswith('vol_type'):
data_volumes_cnt += 1
# Set Data Volumes as specified in the Spawner form
for i in range(1, data_volumes_cnt + 1):
data_volume = {}
# Get all Data Volume fields from the form
id = 'vol_type' + str(i)
if id in formdata and formdata[id][0]:
data_volume['type'] = formdata[id][0].strip()
id = 'vol_name' + str(i)
if id in formdata and formdata[id][0]:
data_volume['name'] = formdata[id][0].strip()
id = 'vol_size' + str(i)
if id in formdata and formdata[id][0]:
data_volume['size'] = '%sGi' % formdata[id][0].strip()
id = 'vol_mount_path' + str(i)
if id in formdata and formdata[id][0]:
data_volume['mountPath'] = formdata[id][0].strip()
id = 'vol_access_modes' + str(i)
if id in formdata and formdata[id][0]:
data_volume['accessModes'] = formdata[id][0].strip()
options['dataVolumes'].append(data_volume)
# Manage Extra Resources
extra_resources_readonly = False
if self._default_config_contains('extraResources'):
options['extraResources'] = (
form_defaults['extraResources']['value'])
extra_resources_readonly = \
form_defaults['extraResources'].get('readOnly', False)
if (not extra_resources_readonly and 'extraResources' in formdata and
formdata['extraResources'][0]):
options['extraResources'] = \
formdata['extraResources'][0].strip()
return options
@property
def singleuser_image_spec(self):
return self.user_options['image']
image_spec = singleuser_image_spec
@property
def cpu_guarantee(self):
return self.user_options['cpu']
@property
def mem_guarantee(self):
return self.user_options['memory']
@property
def workspace_volume(self):
return self.user_options["workspaceVolume"]
@property
def data_volumes(self):
return self.user_options["dataVolumes"]
@property
def extra_resource_limits(self):
extra = ''
if self.user_options['extraResources']:
extra = json.loads(self.user_options['extraResources'])
return extra
def get_env(self):
env = super(KubeFormSpawner, self).get_env()
gcp_secret_name = self.extra_spawner_config['gcp_secret_name']
if gcp_secret_name:
env['GOOGLE_APPLICATION_CREDENTIALS'] = '{}/{}.json'.format(
SERVICE_ACCOUNT_SECRET_MOUNT, gcp_secret_name
)
return env
# TODO(kkasravi): add unit test
def _parse_user_name(self, username):
safe_chars = set(string.ascii_lowercase + string.digits)
name = username.split(':')[-1]
legacy = ''.join([s if s in safe_chars else '-' for s in name.lower()])
safe = escapism.escape(name, safe=safe_chars, escape_char='-').lower()
return legacy, safe, name
def _expand_user_properties(self, template):
# Override KubeSpawner method to remove prefix accounts.google: for iap
legacy, safe, name = self._parse_user_name(self.user.name)
# Set servername based on whether named-server initialised
if self.name:
servername = '-{}'.format(self.name)
else:
servername = ''
rname = template.format(
userid=self.user.id,
username=safe,
unescaped_username=name,
legacy_escape_username=legacy,
servername=servername,
)
return rname
def _default_config_contains(self, option):
"""Check if config.yaml contains a value for a Spawner option."""
if self.spawner_ui_config is not None:
form_defaults = None
if 'spawnerFormDefaults' in self.spawner_ui_config:
form_defaults = self.spawner_ui_config['spawnerFormDefaults']
if form_defaults is not None and option in form_defaults:
if 'value' in form_defaults[option]:
return True
return False
def _get_pvc_manifest(self, name, storage_class, access_modes,
storage, labels, annotations):
"""
Return a PVC spec based on the given parameters.
This manifest will be used to create PVCs in the K8s cluster.
"""
return make_pvc(
name=name,
storage_class=storage_class,
access_modes=access_modes,
storage=storage,
labels=labels,
annotations=annotations
)
def _list_pvcs_in_namespace(self, namespace):
"""
Return a list with all non-failed PVCs in a K8s namespace.
Each list entry is a dict with `name`, `size` and `access_modes` keys.
"""
existing_pvcs = []
try:
resp = self.api.list_namespaced_persistent_volume_claim(
namespace=namespace,
watch=False
)
except ApiException as e:
self.log.warn('Could not list PVCs in %s: %s', namespace, e)
raise
# Iterate over all existing PVCs and return all non-failed ones
for pvc in [pvc for pvc in resp.items if pvc.status.phase != 'Failed']:
existing_pvcs.append({
"name": pvc.metadata.name,
"size": pvc.spec.resources.requests.get('storage')[:-2],
"access_modes": pvc.spec.access_modes
})
return existing_pvcs
@gen.coroutine
def _prepare_volumes(self):
"""Create PVC manifests and attach as volumes to the Notebook."""
# Reset Volumes and VolumeMounts to initial KubeSpawner values
self.volumes = list(self.initial_volumes)
self.volume_mounts = list(self.initial_volume_mounts)
# Workspace and Data Volumes are managed as PVCs
persistent_volumes = [self.workspace_volume] + self.data_volumes
for (idx, volume) in enumerate(persistent_volumes):
if volume['type'] == 'New':
yield self._provision_new_pvc(volume, self.namespace)
elif volume['type'] == 'Existing':
yield self._get_existing_pvc(volume['name'], self.namespace)
# Upon success, mount PVC as a volume
self.volumes.append({
'name': 'volume-%d-{username}' % idx,
'persistentVolumeClaim': {'claimName': volume['name']}
})
self.volume_mounts.append({
'mountPath': volume['mountPath'],
'name': 'volume-%d-{username}' % idx
})
@gen.coroutine
def _provision_new_pvc(self, volume, namespace):
"""Issue a K8s API request to create a new, namespaced PVC."""
labels = self._build_common_labels(self._expand_all(
self.user_storage_extra_labels))
labels.update({'component': 'singleuser-storage'})
annotations = self._build_common_annotations({})
# Create a V1PersistentVolumeClaim for the API call
pvc_manifest = self._get_pvc_manifest(
name=volume['name'],
storage_class=self.extra_spawner_config['storage_class'],
access_modes=[volume['accessModes']],
storage=volume['size'],
labels=labels,
annotations=annotations
)
pvc = None
try:
pvc = yield self.asynchronize(
self.api.create_namespaced_persistent_volume_claim,
namespace=namespace,
body=pvc_manifest
)
except ApiException as e:
if e.status == 409:
self.log.warning('PVC %s already exists. New PVC not created.',
volume['name'])
self.log.info(e.reason)
raise
self.log.info('PVC %s was successfully created', volume['name'])
return pvc
@gen.coroutine
def _get_existing_pvc(self, pvc_name, namespace):
"""Issue a K8s API request to retrieve a namespaced PVC."""
pvc = None
try:
pvc = yield self.asynchronize(
self.api.read_namespaced_persistent_volume_claim,
name=pvc_name,
namespace=namespace
)
except ApiException as e:
self.log.warning('PVC %s could not be retrieved: %s', pvc_name, e)
raise
self.log.info('PVC %s was successfully retrieved', pvc_name)
return pvc
@gen.coroutine
def start(self):
"""Override KubeSpawner class start method."""
yield self._prepare_volumes()
_start = yield super(KubeFormSpawner, self).start()
return _start
spawner_ui_config.yaml: |
# Configuration file for the default JupyterHub Spawner UI
# Each key corresponds to a JupyterHub Spawner UI option
# If a key is missing, the respective Spawner UI option will be left untouched
#
# Each Spawner UI option is configured by two keys: `value` and `readOnly`
# - The `value` key contains the default value
# - The `readOnly` key determines if the option will be available to users
#
# If the 'readOnly' key is present and set to 'true', the respective option
# will be disabled for users and only set by the admin
# If the 'readOnly' key is missing (defaults to 'false'), the respective option
# will be available for users
#
# Please note that some values (e.g. {servername}, {username}) may be templated
# and expanded according to KubeSpawner's rules
#
# For more information regarding JupyterHub KubeSpawner and its configuration:
# https://jupyterhub-kubespawner.readthedocs.io/en/latest/spawner.html
spawnerFormDefaults:
image:
# The container Image for the user's Jupyter Notebook
# If readonly, this value must be a member of the list below
value: gcr.io/kubeflow-images-public/tensorflow-1.10.1-notebook-cpu:v0.4.0
# The list of available standard container Images
options:
- gcr.io/kubeflow-images-public/tensorflow-1.4.1-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.4.1-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.5.1-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.5.1-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.6.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.6.0-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.7.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.7.0-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.8.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.8.0-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.9.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.9.0-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.10.1-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.10.1-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.11.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.11.0-notebook-gpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.12.0-notebook-cpu:v0.4.0
- gcr.io/kubeflow-images-public/tensorflow-1.12.0-notebook-gpu:v0.4.0
# By default, custom container Images are allowed
# Uncomment the following line to only enable standard container Images
#readOnly: true
cpu:
# CPU for user's Notebook
value: '1.0'
memory:
# Memory for user's Notebook
value: 1.0Gi
workspaceVolume:
# Workspace Volume to be attached to user's Notebook
# Each Workspace Volume is declared with the following attributes:
# Type, Name, Size, MountPath and Access Mode
value:
type:
# The Type of the Workspace Volume
# Supported values: 'New', 'Existing'
value: New
name:
# The Name of the Workspace Volume
# Note that this is a templated value
value: {username}{servername}-workspace
size:
# The Size of the Workspace Volume (in Gi)
value: '10'
mountPath:
# The Path that the Workspace Volume will be mounted
readOnly: true
value: /home/jovyan
accessModes:
# The Access Mode of the Workspace Volume
# Supported values: 'ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany'
value: ReadWriteOnce
dataVolumes:
# List of additional Data Volumes to be attached to the user's Notebook
value: []
# Each Data Volume is declared with the following attributes:
# Type, Name, Size, MountPath and Access Mode
#
# For example, a list with 2 Data Volumes:
#value:
# - value:
# type:
# value: New
# name:
# value: {username}{servername}-vol-1
# size:
# value: '10'
# mountPath:
# value: /home/jovyan/{username}{servername}-vol-1
# accessModes:
# value: ReadWriteOnce
# - value:
# type:
# value: New
# name:
# value: {username}{servername}-vol-2
# size:
# value: '5'
# mountPath:
# value: /home/jovyan/{username}{servername}-vol-2
# accessModes:
# value: ReadWriteOnce
#
# Uncomment the following line to make the Data Volumes list readonly
#readOnly: true
extraResources:
# Extra Resource Limits for user's Notebook
# Note that braces are escaped
value: "{{}}"
style.css: |
body {
font-family:
"Lato", -apple-system, BlinkMacSystemFont, "Avenir Next",
"Avenir", "Segoe UI", "Lucida Grande", "Helvetica Neue", "Helvetica",
"Fira Sans", "Roboto", "Noto", "Droid Sans", "Cantarell", "Oxygen",
"Ubuntu", "Franklin Gothic Medium", "Century Gothic", "Liberation Sans",
sans-serif;
}
b, strong {
font-weight: 600;
}
.panel-primary>.panel-heading {
padding-bottom: 4.5px;
}
.btn-jupyter[disabled] {
background-color: #F37524;
border-color: #E34F21;
}
.btn-success {
color: #fff;
background-color: #28a745;
border-color: #28a745;
}
.btn-success:hover {
color: #fff;
background-color: #218838;
border-color: #1e7e34;
}
.btn-success:active:focus {
color: #fff;
background-color: #28a745;
border-color: #28a745;
}
.btn-success:focus {
color: #fff;
background-color: #28a745;
border-color: #28a745;
}
input[readonly] {
cursor: not-allowed;
}
select[readonly] {
cursor: not-allowed;
}
.col-sm-3 {
padding-left: 10px;
padding-right: 10px;
}
template.html: |
{% block css %}
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.3.1/css/all.css">
<style type="text/css">
{% include "style.css" %}
</style>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
var existingPVCs = {{ existing_pvcs|tojson }};
var formDefaults = {{ form_defaults|tojson }};
var username = {{ username|tojson }};
{% include "script.js" %}
</script>
{% endblock %}
{% block existing_pvcs %}
{% if form_defaults is not none %}
<datalist id="suggest_pvcs">
{% for pvc in existing_pvcs %}
<option value="{{ pvc.name }}">
{% endfor %}
</datalist>
{% endif %}
{% endblock %}
{% block error_message %}
{% if form_defaults is none %}
<div style="display: inline-block; text-align: left;padding: 10px;">
<h3>The <code>config.yaml</code> file contains invalid YAML syntax</h3>
<h4>Please follow the steps below to address this issue:</h4>
<p class="help-block">
1. Correct all YAML syntax errors in the <code>config.yaml</code> file<br>
2. Ask your administrator to restart the JupyterHub server<br>
3. Return at this page and log in to view the Spawner form
</p>
</div>
{% endif %}
{% endblock %}
{% block header %}
{% if form_defaults is not none %}
<div class="panel-info">
<div class="panel-heading">Fill out the form to customize your Jupyter Notebook.</div>
</div>
{% endif %}
{% endblock %}
{% block image %}
{% if form_defaults is not none %}
<!-- Image -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="fab fa-docker"></i>
<label>Image</label>
</div>
<div class="panel-body" style="padding: 10px;">
<div id='imageType' style="padding-bottom: 5px;">
<label class="radio-inline">
<input id="option_standard" type="radio" name="imageType" onclick="setImageType()"
value="standard" checked>Standard
</label>
<label class="radio-inline">
<input id="option_custom" type="radio" name="imageType" onclick="setImageType()"
value="custom">Custom
</label>
</div>
<select class="form-control" for="standardImages" required></select>
<input class="form-control" for="customImage" placeholder="repo/image:tag" required>
</div>
<p class="text-muted" style="padding: 10px;">
A starter Docker image for JupyterHub with a baseline deployment and typical ML packages.
</p>
</div>
{% endif %}
{% endblock %}
{% block toggle_advanced_button %}
{% if form_defaults is not none %}
<!-- Advanced Options Toggle Button -->
<div style="text-align: center; padding: 10px;">
<a id="toggle_advanced_options" class="btn btn-primary">Toggle Advanced</a>
</div>
{% endif %}
{% endblock %}
{% block advanced_fields %}
{% if form_defaults is not none %}
<div id="advanced_fields" style="display: none;">
{% block cpu %}
<!-- CPU -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="fas fa-microchip"></i>
<label>CPU</label>
</div>
<div class="panel-body" style="padding: 10px;">
<input class="form-control" id='cpu' name='cpu'
placeholder='200m, 2.5, etc' required>
</input>
</div>
<p class="text-muted" style="padding: 10px;">
For CPU-intensive workloads, you can choose more than 1 CPU
(e.g. <span><code>1.5</code></span>).
</p>
</div>
{% endblock %}
{% block memory %}
<!-- Memory -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="fas fa-memory"></i>
<label>Memory</label>
</div>
<div class="panel-body" style="padding: 10px;">
<input class="form-control" id='memory' name='memory'
placeholder='100Mi, 1.5Gi, etc' required>
</input>
</div>
<p class="text-muted" style="padding: 10px;">
Specify the total amount of RAM reserved by your Notebook
(e.g. <span><code>2.0Gi</code></span>).
</p>
</div>
{% endblock %}
{% block workspaceVolume %}
<!-- Workspace Volume -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="fas fa-laptop-code" style="padding: 0px 2px;"></i>
<label>Workspace Volume</label>
</div>
<div class="panel-body" id="workspace_volume">
<div class="col-sm-2" style="width: 14%"><label>Type</label></div>
<div class="col-sm-3" style="width: 20%"><label>Name</label></div>
<div class="col-sm-2" style="width: 12%"><label>Size (Gi)</label></div>
<div class="col-sm-3" style="width: 29%"><label>Mount Path</label></div>
<div class="col-sm-3" style="width: 19%"><label>Access Mode</label></div>
<div class="col-sm-*">
<div class="col-sm-2" style="width: 14%">
<select class="form-control" name="ws_type" id="ws_type">
<option selected>New</option>
<option>Existing</option>
</select>
</div>
<div class="col-sm-3" style="width: 20%">
<input class="form-control" name="ws_name" id="ws_name"
list="suggest_pvcs_disabled" required>
</input>
</div>
<div class="col-sm-2" style="width: 12%">
<input class="form-control" name="ws_size" id="ws_size" placeholder='10'
type="number" step="0.5" min="0" required>
</input>
</div>
<div class="col-sm-3" style="width: 29%">
<input class="form-control" id="ws_mount_path" name="ws_mount_path" required></input>
</div>
<div class="col-sm-3" style="width: 19%">
<select class="form-control" id="ws_access_modes" name="ws_access_modes" required>
<option value="ReadWriteOnce">ReadWriteOnce</option>
<option value="ReadWriteMany">ReadWriteMany</option>
<option value="ReadOnlyMany">ReadOnlyMany</option>
</select>
</div>
</div>
</div>
<p class="text-muted" style="padding: 10px;">
Configure the Volume to be mounted as your personal Workspace.</br>
For example, to create an empty Workspace:
<span><code>New</code></span>
<span><code>{{ username }}-workspace</code></span>,
<span><code>10</code></span>,
<span><code>/home/jovyan</code></span>,
<span><code>ReadWriteOnce</code></span>
</p>
</div>
{% endblock %}
{% block dataVolumes %}
<!-- Data Volumes -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="far fa-hdd" style="padding: 0px 2px;"></i>
<label>Data Volumes</label>
</div>
<div class="panel-body">
<div class="col-sm-2" style="width: 14%"><label>Type</label></div>
<div class="col-sm-3" style="width: 20%"><label>Name</label></div>
<div class="col-sm-2" style="width: 12%"><label>Size (Gi)</label></div>
<div class="col-sm-3" style="width: 29%"><label>Mount Path</label> </div>
<div class="col-sm-3" style="width: 19%"><label>Access Mode</label></div>
<div class="col-sm-*" id="data_volumes"></div>
<div class="col-sm-2">
<button id="add_volume" class="btn btn-success btn-sm" type="button" onclick="addVolume();">
<i class="fas fa-plus"></i>
</button>
</div>
</div>
<p class="text-muted" style="padding: 10px;">
Configure the Volumes to be mounted as your Datasets.</br>
For example, to create an empty Data Volume:
<span><code>New</code></span>,
<span><code>{{ username }}-volume-1</code></span>,
<span><code>5</code></span>,
<span><code>/home/jovyan/{{ username }}-volume-1</code></span>,
<span><code>ReadWriteOnce</code></span>
</p>
</div>
{% endblock %}
{% block extra_resources %}
<!-- Extra Resources -->
<div class="panel panel-primary">
<div class="panel-heading">
<i class="fas fa-cogs"></i>
<label>Extra Resources</label>
</div>
<div class="panel-body" style="padding: 10px;">
{% raw %}
<input class="form-control" id="extraResources" name='extraResources' placeholder='{"nvidia.com/gpu": 3}' required></input>
{% endraw %}
</div>
<p class="text-muted" style="padding: 10px;">
Reserve additional resources.</br>
For example, to reserve 2 GPUs: <span><code>{"nvidia.com/gpu": 2}</code></span>
</p>
</div>
{% endblock %}
</div>
{% endif %}
{% endblock %}
{% block footer %}
{% if form_defaults is not none %}
<div class="panel-warning">
<div class="panel-heading">
In case your Jupyter Notebook does not start, make sure that the resource quotas you specified are available in the cluster.
</div>
</div>
{% endif %}
{% endblock %}
kind: ConfigMap
metadata:
name: jupyter-config
- apiVersion: v1
kind: ServiceAccount
metadata:
labels:
app: jupyter
name: jupyter
- apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: jupyter-role
rules:
- apiGroups:
- ""
resources:
- pods
- persistentvolumeclaims
verbs:
- get
- watch
- list
- create
- delete
- apiGroups:
- ""
resources:
- events
- secrets
verbs:
- get
- watch
- list
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: jupyter-role
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: jupyter-role
subjects:
- kind: ServiceAccount
name: jupyter
- apiVersion: v1
kind: Service
metadata:
labels:
app: jupyter
name: jupyter-0
spec:
clusterIP: None
ports:
- name: hub
port: 8000
selector:
app: jupyter
- apiVersion: v1
kind: Service
metadata:
labels:
app: jupyter-lb
name: jupyter-lb
spec:
ports:
- name: hub
port: 80
targetPort: 8000
selector:
app: jupyter
type: LoadBalancer
- apiVersion: apps/v1
kind: StatefulSet
metadata:
name: jupyter
spec:
replicas: 1
serviceName: ""
selector:
matchLabels:
app: jupyter
template:
metadata:
labels:
app: jupyter
spec:
containers:
- command:
- jupyterhub
- -f
- /etc/config/jupyter_config.py
env:
- name: KF_AUTHENTICATOR
value: "null"
- name: DEFAULT_JUPYTERLAB
value: "false"
- name: STORAGE_CLASS
value: "null"
image: gcr.io/kubeflow-images-public/jupyterhub-k8s:v20181218-8468ca1c-dirty-269c6c
name: jupyter
ports:
- containerPort: 8000
- containerPort: 8081
volumeMounts:
- mountPath: /etc/config
name: config-volume
serviceAccountName: jupyter
volumes:
- configMap:
name: jupyter-config
name: config-volume
updateStrategy:
type: RollingUpdate
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment