diff --git a/config.go b/config.go new file mode 100644 index 00000000..204691bb --- /dev/null +++ b/config.go @@ -0,0 +1,94 @@ +package main + +import "net/http" + +type ConfigurationsConfiguration struct { + Value []string `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type SharedMemoryConfiguration struct { + Value bool `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type GPUVendorConfiguration struct { + LimitsKey string `yaml:"limitsKey" json:"limitsKey"` + UIName string `yaml:"uiName" json:"uiName"` +} + +type GPUValueConfiguration struct { + Quantity string `yaml:"num" json:"num"` + Vendors []GPUVendorConfiguration `yaml:"vendors" json:"vendors"` + Vendor string `yaml:"vendor" json:"vendor"` +} + +type GPUConfiguration struct { + Value GPUValueConfiguration `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type ValueConfiguration struct { + Value string `yaml:"value" json:"value"` +} + +type VolumeValueConfiguration struct { + Type ValueConfiguration `yaml:"type" json:"type"` + Name ValueConfiguration `yaml:"name" json:"name"` + Size ValueConfiguration `yaml:"size" json:"size"` + MountPath ValueConfiguration `yaml:"mountPath" json:"mountPath"` + AccessModes ValueConfiguration `yaml:"accessModes" json:"accessModes"` + Class ValueConfiguration `yaml:"class" json:"class"` +} + +type DataVolumesConfiguration struct { + Values []VolumeValueConfiguration `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type WorkspaceVolumeConfiguration struct { + Value VolumeValueConfiguration `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type ResourceConfiguration struct { + Value string `yaml:"value" json:"value"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` +} + +type ImageConfiguration struct { + Value string `yaml:"value" json:"value"` + Options []string `yaml:"options" json:"options"` + ReadOnly bool `yaml:"readOnly" json:"readOnly"` + HideRegistry bool `yaml:"hideRegistry" json:"hideRegistry"` + HideVersion bool `yaml:"hideVersion" json:"hideVersion"` +} + +type SpawnerFormDefaults struct { + Image ImageConfiguration `yaml:"image" json:"image"` + CPU ResourceConfiguration `yaml:"cpu" json:"cpu"` + Memory ResourceConfiguration `yaml:"memory" json:"memory"` + WorkspaceVolume WorkspaceVolumeConfiguration `yaml:"workspaceVolume" json:"workspaceVolume"` + DataVolumes DataVolumesConfiguration `yaml:"dataVolumes" json:"dataVolumes"` + GPUs GPUConfiguration `yaml:"gpus" json:"gpus"` + SharedMemory SharedMemoryConfiguration `yaml:"shm" json:"shm"` + Configurations ConfigurationsConfiguration `yaml:"configurations" json:"configurations"` +} + +type Configuration struct { + SpawnerFormDefaults SpawnerFormDefaults `yaml:"spawnerFormDefaults" json:"spawnerFormDefaults"` +} + +type configresponse struct { + APIResponse + Config SpawnerFormDefaults `json:"config"` +} + +func (s *server) GetConfig(w http.ResponseWriter, r *http.Request) { + s.respond(w, r, configresponse{ + APIResponse: APIResponse{ + Success: true, + }, + Config: s.Config.SpawnerFormDefaults, + }) +} diff --git a/go.mod b/go.mod index 77526ca3..6ff99e0d 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( golang.org/x/text v0.3.3 // indirect golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/yaml.v2 v2.3.0 k8s.io/api v0.18.6 k8s.io/apimachinery v0.18.6 k8s.io/client-go v11.0.1-0.20190409021438-1a26190bd76a+incompatible diff --git a/main.go b/main.go index 2e5b3c15..065a1b45 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "flag" + "io/ioutil" "log" "net/http" "os" @@ -18,6 +19,7 @@ import ( kubeflowv1alpha1listers "github.com/StatCan/kubeflow-controller/pkg/generated/listers/kubeflowcontroller/v1alpha1" "github.com/gorilla/handlers" "github.com/gorilla/mux" + "gopkg.in/yaml.v2" authorizationv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes" @@ -28,6 +30,7 @@ import ( ) var kubeconfig string +var spawnerConfigPath string var userIDHeader string type listers struct { @@ -46,6 +49,8 @@ type clientsets struct { type server struct { mux sync.Mutex + Config Configuration + clientsets clientsets listers listers } @@ -63,6 +68,24 @@ func main() { } flag.StringVar(&userIDHeader, "userid-header", "kubeflow-userid", "header in the request which identifies the incoming user") + flag.StringVar(&spawnerConfigPath, "spawner-config", "/etc/config/spawner_ui_config.yaml", "path to the spawner configuration file") + + // Parse flags + flag.Parse() + + // Setup the server + s := server{} + + // Parse config + cfdata, err := ioutil.ReadFile(spawnerConfigPath) + if err != nil { + log.Fatal(err) + } + + err = yaml.Unmarshal(cfdata, &s.Config) + if err != nil { + log.Fatal(err) + } // Construct the configuration based on the provided flags. // If no config file is provided, then the in-cluster config is used. @@ -71,8 +94,6 @@ func main() { log.Fatal(err) } - s := server{} - // Generate the Kubernetes clientset s.clientsets.kubernetes, err = kubernetes.NewForConfig(config) if err != nil { @@ -91,6 +112,7 @@ func main() { router := mux.NewRouter() // Setup route handlers + router.HandleFunc("/api/config", s.GetConfig).Methods("GET") router.HandleFunc("/api/storageclasses/default", s.GetDefaultStorageClass).Methods("GET") router.HandleFunc("/api/namespaces/{namespace}/notebooks", s.checkAccess(authorizationv1.SubjectAccessReview{ diff --git a/notebooks.go b/notebooks.go index 805294ab..9974ba79 100644 --- a/notebooks.go +++ b/notebooks.go @@ -22,7 +22,6 @@ import ( const DefaultServiceAccountName string = "default-editor" const SharedMemoryVolumeName string = "dshm" const SharedMemoryVolumePath string = "/dev/shm" -const WorkspacePath string = "/home/jovyan" type volumetype string @@ -294,6 +293,9 @@ func (s *server) NewNotebook(w http.ResponseWriter, r *http.Request) { if req.CustomImageCheck { image = req.CustomImage } + if s.Config.SpawnerFormDefaults.Image.ReadOnly { + image = s.Config.SpawnerFormDefaults.Image.Value + } // Setup the notebook // TODO: Work with default CPU/memory limits from config @@ -311,14 +313,8 @@ func (s *server) NewNotebook(w http.ResponseWriter, r *http.Request) { Name: req.Name, Image: image, Resources: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceCPU: req.CPU, - corev1.ResourceMemory: req.Memory, - }, - Requests: corev1.ResourceList{ - corev1.ResourceCPU: req.CPU, - corev1.ResourceMemory: req.Memory, - }, + Requests: corev1.ResourceList{}, + Limits: corev1.ResourceList{}, }, }, }, @@ -327,26 +323,97 @@ func (s *server) NewNotebook(w http.ResponseWriter, r *http.Request) { }, } - // Add workspace volume - if !req.NoWorkspace { - req.Workspace.Path = WorkspacePath - err = s.handleVolume(r.Context(), req.Workspace, ¬ebook) + // Resources + if s.Config.SpawnerFormDefaults.CPU.ReadOnly { + val, err := resource.ParseQuantity(s.Config.SpawnerFormDefaults.CPU.Value) if err != nil { s.error(w, r, err) return } + + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceCPU] = val + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceCPU] = val + } else { + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceCPU] = req.CPU + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceCPU] = req.CPU } - for _, volreq := range req.DataVolumes { - err = s.handleVolume(r.Context(), volreq, ¬ebook) + if s.Config.SpawnerFormDefaults.Memory.ReadOnly { + val, err := resource.ParseQuantity(s.Config.SpawnerFormDefaults.Memory.Value) if err != nil { s.error(w, r, err) return } + + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = val + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory] = val + } else { + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceMemory] = req.Memory + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceMemory] = req.Memory + } + + // Add workspace volume + if s.Config.SpawnerFormDefaults.WorkspaceVolume.ReadOnly { + size, err := resource.ParseQuantity(s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.Size.Value) + if err != nil { + s.error(w, r, err) + return + } + + workspaceVol := volumerequest{ + Name: s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.Name.Value, + Size: size, + Path: s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.MountPath.Value, + Mode: corev1.PersistentVolumeAccessMode(s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.AccessModes.Value), + Class: s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.Class.Value, + } + err = s.handleVolume(r.Context(), workspaceVol, ¬ebook) + if err != nil { + s.error(w, r, err) + return + } + } else if !req.NoWorkspace { + req.Workspace.Path = s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.MountPath.Value + err = s.handleVolume(r.Context(), req.Workspace, ¬ebook) + if err != nil { + s.error(w, r, err) + return + } + } + + if s.Config.SpawnerFormDefaults.DataVolumes.ReadOnly { + for _, volreq := range s.Config.SpawnerFormDefaults.DataVolumes.Values { + size, err := resource.ParseQuantity(s.Config.SpawnerFormDefaults.WorkspaceVolume.Value.Size.Value) + if err != nil { + s.error(w, r, err) + return + } + + vol := volumerequest{ + Name: volreq.Name.Value, + Size: size, + Path: volreq.MountPath.Value, + Mode: corev1.PersistentVolumeAccessMode(volreq.AccessModes.Value), + Class: volreq.Class.Value, + } + err = s.handleVolume(r.Context(), vol, ¬ebook) + if err != nil { + s.error(w, r, err) + return + } + } + } else { + for _, volreq := range req.DataVolumes { + err = s.handleVolume(r.Context(), volreq, ¬ebook) + if err != nil { + s.error(w, r, err) + return + } + } } // Add shared memory, if enabled - if req.EnableSharedMemory { + if (s.Config.SpawnerFormDefaults.SharedMemory.ReadOnly && s.Config.SpawnerFormDefaults.SharedMemory.Value) || (!s.Config.SpawnerFormDefaults.SharedMemory.ReadOnly && req.EnableSharedMemory) { notebook.Spec.Template.Spec.Volumes = append(notebook.Spec.Template.Spec.Volumes, corev1.Volume{ Name: SharedMemoryVolumeName, VolumeSource: corev1.VolumeSource{ @@ -363,15 +430,28 @@ func (s *server) NewNotebook(w http.ResponseWriter, r *http.Request) { } // Add GPU - if req.GPUs.Quantity != "none" { - qty, err := resource.ParseQuantity(req.GPUs.Quantity) - if err != nil { - s.error(w, r, err) - return + if s.Config.SpawnerFormDefaults.GPUs.ReadOnly { + if s.Config.SpawnerFormDefaults.GPUs.Value.Quantity != "none" { + qty, err := resource.ParseQuantity(s.Config.SpawnerFormDefaults.GPUs.Value.Quantity) + if err != nil { + s.error(w, r, err) + return + } + + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceName(s.Config.SpawnerFormDefaults.GPUs.Value.Vendor)] = qty + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceName(s.Config.SpawnerFormDefaults.GPUs.Value.Vendor)] = qty } + } else { + if req.GPUs.Quantity != "none" { + qty, err := resource.ParseQuantity(req.GPUs.Quantity) + if err != nil { + s.error(w, r, err) + return + } - notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceName(req.GPUs.Vendor)] = qty - notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceName(req.GPUs.Vendor)] = qty + notebook.Spec.Template.Spec.Containers[0].Resources.Requests[corev1.ResourceName(req.GPUs.Vendor)] = qty + notebook.Spec.Template.Spec.Containers[0].Resources.Limits[corev1.ResourceName(req.GPUs.Vendor)] = qty + } } log.Printf("creating notebook %q for %q", notebook.ObjectMeta.Name, namespace) diff --git a/samples/spawner_ui_config.yaml b/samples/spawner_ui_config.yaml new file mode 100644 index 00000000..ccd033e3 --- /dev/null +++ b/samples/spawner_ui_config.yaml @@ -0,0 +1,134 @@ +# Configuration file for the Jupyter UI. +# +# Each Jupyter 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. Also when a +# Notebook is POSTED to the API if a necessary field is not present then +# the value from the config will be used. +# +# If the 'readOnly' key is missing (defaults to 'false'), the respective option +# will be available for users to edit. +# +# Note that some values can be templated. Such values are the names of the +# Volumes as well as their StorageClass +spawnerFormDefaults: + image: + # The container Image for the user's Jupyter Notebook + # If readonly, this value must be a member of the list below + value: k8scc01covidacr.azurecr.io/minimal-notebook-cpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + # The list of available standard container Images + options: + - k8scc01covidacr.azurecr.io/minimal-notebook-cpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/minimal-notebook-gpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/geomatics-notebook-cpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/machine-learning-notebook-cpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/machine-learning-notebook-gpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/r-studio-cpu:d58600076b0c188364d8651d3986ee0d37ecb4ad + - k8scc01covidacr.azurecr.io/remote-desktop-r:6fb4bcafdfc4f754d62fa7de66f05f889dab7428 + - k8scc01covidacr.azurecr.io/remote-desktop-geomatics:6fb4bcafdfc4f754d62fa7de66f05f889dab7428 + # By default, custom container Images are allowed + # Uncomment the following line to only enable standard container Images + readOnly: false + hideRegistry: true + hideVersion: true + cpu: + # CPU for user's Notebook + value: '0.5' + readOnly: false + memory: + # Memory for user's Notebook + value: 1.0Gi + readOnly: false + 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. Special values: + # {notebook-name}: Replaced with the name of the Notebook. The frontend + # will replace this value as the user types the name + value: 'workspace-{notebook-name}' + size: + # The Size of the Workspace Volume (in Gi) + value: '10Gi' + mountPath: + # The Path that the Workspace Volume will be mounted + value: /home/jovyan + accessModes: + # The Access Mode of the Workspace Volume + # Supported values: 'ReadWriteOnce', 'ReadWriteMany', 'ReadOnlyMany' + value: ReadWriteOnce + class: + # The StrageClass the PVC will use if type is New. Special values are: + # {none}: default StorageClass + # {empty}: empty string "" + value: '{none}' + readOnly: false + 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: '{notebook-name}-vol-1' + # size: + # value: '10Gi' + # class: + # value: standard + # mountPath: + # value: /home/jovyan/vol-1 + # accessModes: + # value: ReadWriteOnce + # class: + # value: {none} + # - value: + # type: + # value: New + # name: + # value: '{notebook-name}-vol-2' + # size: + # value: '10Gi' + # mountPath: + # value: /home/jovyan/vol-2 + # accessModes: + # value: ReadWriteMany + # class: + # value: {none} + readOnly: false + gpus: + # Number of GPUs to be assigned to the Notebook Container + value: + # values: "none", "1", "2", "4", "8" + num: "none" + # Determines what the UI will show and send to the backend + vendors: + - limitsKey: "nvidia.com/gpu" + uiName: "NVIDIA" + # Values: "" or a `limits-key` from the vendors list + vendor: "" + readOnly: false + shm: + value: true + readOnly: false + configurations: + # List of labels to be selected, these are the labels from PodDefaults + # value: + # - add-gcp-secret + # - default-editor + value: [] + readOnly: false