This commit is contained in:
Harshad Reddy Nalla 2025-09-19 17:04:38 -04:00 committed by GitHub
commit b34f1b85bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 371 additions and 163 deletions

View File

@ -245,12 +245,19 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
VolumeMounts: kubefloworgv1beta1.WorkspaceKindVolumeMounts{
Home: "/home/jovyan",
},
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{"X-RStudio-Root-Path": "{{ .PathPrefix }}"},
Add: map[string]string{},
Remove: []string{},
Ports: []kubefloworgv1beta1.WorkspaceKindPort{
{
Id: "jupyterlab",
DefaultDisplayName: "JupyterLab",
Protocol: "HTTP",
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{},
Add: map[string]string{},
Remove: []string{},
},
},
},
},
ExtraEnv: []v1.EnvVar{
@ -317,9 +324,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
DisplayName: ptr.To("JupyterLab"),
Port: 8888,
Protocol: "HTTP",
},
},
},
@ -341,10 +347,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Image: "ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0",
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
Port: 8888,
Protocol: "HTTP",
Id: "jupyterlab",
Port: 8888,
},
},
},

View File

@ -299,7 +299,6 @@ spec:
- id: "jupyterlab"
displayName: "JupyterLab"
port: 8888
protocol: "HTTP"
podConfig:
spawner:
default: "tiny_cpu"

View File

@ -91,6 +91,10 @@ func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubef
imageConfigModel, imageConfigValue := buildImageConfig(ws, wsk)
podConfigModel, _ := buildPodConfig(ws, wsk)
var wskPodTemplatePorts []kubefloworgv1beta1.WorkspaceKindPort
if wskExists(wsk) {
wskPodTemplatePorts = wsk.Spec.PodTemplate.Ports
}
workspaceModel := Workspace{
Name: ws.Name,
@ -128,7 +132,7 @@ func NewWorkspaceModelFromWorkspace(ws *kubefloworgv1beta1.Workspace, wsk *kubef
// https://github.com/kubeflow/notebooks/issues/38
LastProbe: nil,
},
Services: buildServices(ws, imageConfigValue),
Services: buildServices(ws, wskPodTemplatePorts, imageConfigValue),
}
return workspaceModel
}
@ -332,7 +336,7 @@ func buildRedirectMessage(msg *kubefloworgv1beta1.RedirectMessage) *RedirectMess
}
}
func buildServices(ws *kubefloworgv1beta1.Workspace, imageConfigValue *kubefloworgv1beta1.ImageConfigValue) []Service {
func buildServices(ws *kubefloworgv1beta1.Workspace, wskPodTemplatePorts []kubefloworgv1beta1.WorkspaceKindPort, imageConfigValue *kubefloworgv1beta1.ImageConfigValue) []Service {
if imageConfigValue == nil {
return nil
}
@ -340,10 +344,12 @@ func buildServices(ws *kubefloworgv1beta1.Workspace, imageConfigValue *kubeflowo
services := make([]Service, len(imageConfigValue.Spec.Ports))
for i := range imageConfigValue.Spec.Ports {
port := imageConfigValue.Spec.Ports[i]
switch port.Protocol { //nolint:gocritic
portocol := wskPodTemplatePorts[i].Protocol
// golint complains about the single case in switch statement
switch portocol { //nolint:gocritic
case kubefloworgv1beta1.ImagePortProtocolHTTP:
services[i].HttpService = &HttpService{
DisplayName: port.DisplayName,
DisplayName: ptr.Deref(port.DisplayName, wskPodTemplatePorts[i].DefaultDisplayName),
HttpPath: fmt.Sprintf("/workspace/%s/%s/%s/", ws.Namespace, ws.Name, port.Id),
}
}

View File

@ -29,6 +29,13 @@ import (
===============================================================================
*/
// PortId the id of the port
//
// +kubebuilder:validation:MinLength:=1
// +kubebuilder:validation:MaxLength:=32
// +kubebuilder:validation:Pattern:=^[a-z0-9][a-z0-9_-]*[a-z0-9]$
type PortId string
// WorkspaceKindSpec defines the desired state of WorkspaceKind
type WorkspaceKindSpec struct {
@ -115,9 +122,11 @@ type WorkspaceKindPodTemplate struct {
// volume mount paths
VolumeMounts WorkspaceKindVolumeMounts `json:"volumeMounts"`
// http proxy configs (MUTABLE)
// +kubebuilder:validation:Optional
HTTPProxy *HTTPProxy `json:"httpProxy,omitempty"`
// ports that the container listens on
// +kubebuilder:validation:MinItems:=1
// +listType:="map"
// +listMapKey:="id"
Ports []WorkspaceKindPort `json:"ports,omitempty"`
// environment variables for Workspace Pods (MUTABLE)
// - the following go template functions are available:
@ -151,6 +160,27 @@ type WorkspaceKindPodTemplate struct {
Options WorkspaceKindPodOptions `json:"options"`
}
type WorkspaceKindPort struct {
// the id of the port
// - identifier for the port in `imageconfig` ports.[].id
// +kubebuilder:example="jupyterlab"
Id PortId `json:"id"`
// the protocol of the port
// +kubebuilder:example:="HTTP"
Protocol ImagePortProtocol `json:"protocol"`
// the display name of the port
// +kubebuilder:validation:MinLength:=2
// +kubebuilder:validation:MaxLength:=64
// +kubebuilder:example:="JupyterLab"
DefaultDisplayName string `json:"displayName"`
// the http proxy config for the port (MUTABLE)
// +kubebuilder:validation:Optional
HTTPProxy *HTTPProxy `json:"httpProxy,omitempty"`
}
type WorkspaceKindPodMetadata struct {
// labels to be applied to the Pod resource
// +kubebuilder:validation:Optional
@ -339,11 +369,8 @@ type ImageConfigSpec struct {
type ImagePort struct {
// the id of the port
// - this is NOT used as the Container or Service port name, but as part of the HTTP path
// +kubebuilder:validation:MinLength:=1
// +kubebuilder:validation:MaxLength:=32
// +kubebuilder:validation:Pattern:=^[a-z0-9][a-z0-9_-]*[a-z0-9]$
// +kubebuilder:example="jupyterlab"
Id string `json:"id"`
Id PortId `json:"id"`
// the port number
// +kubebuilder:validation:Minimum:=1
@ -354,12 +381,8 @@ type ImagePort struct {
// the display name of the port
// +kubebuilder:validation:MinLength:=2
// +kubebuilder:validation:MaxLength:=64
// +kubebuilder:example:="JupyterLab"
DisplayName string `json:"displayName"`
// the protocol of the port
// +kubebuilder:example:="HTTP"
Protocol ImagePortProtocol `json:"protocol"`
// +kubebuilder:validation:Optional
DisplayName *string `json:"displayName,omitempty"`
}
// +kubebuilder:validation:Enum:={"HTTP"}

View File

@ -144,7 +144,9 @@ func (in *ImageConfigSpec) DeepCopyInto(out *ImageConfigSpec) {
if in.Ports != nil {
in, out := &in.Ports, &out.Ports
*out = make([]ImagePort, len(*in))
copy(*out, *in)
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
@ -183,6 +185,11 @@ func (in *ImageConfigValue) DeepCopy() *ImageConfigValue {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ImagePort) DeepCopyInto(out *ImagePort) {
*out = *in
if in.DisplayName != nil {
in, out := &in.DisplayName, &out.DisplayName
*out = new(string)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImagePort.
@ -701,10 +708,12 @@ func (in *WorkspaceKindPodTemplate) DeepCopyInto(out *WorkspaceKindPodTemplate)
(*in).DeepCopyInto(*out)
}
out.VolumeMounts = in.VolumeMounts
if in.HTTPProxy != nil {
in, out := &in.HTTPProxy, &out.HTTPProxy
*out = new(HTTPProxy)
(*in).DeepCopyInto(*out)
if in.Ports != nil {
in, out := &in.Ports, &out.Ports
*out = make([]WorkspaceKindPort, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.ExtraEnv != nil {
in, out := &in.ExtraEnv, &out.ExtraEnv
@ -750,6 +759,26 @@ func (in *WorkspaceKindPodTemplate) DeepCopy() *WorkspaceKindPodTemplate {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkspaceKindPort) DeepCopyInto(out *WorkspaceKindPort) {
*out = *in
if in.HTTPProxy != nil {
in, out := &in.HTTPProxy, &out.HTTPProxy
*out = new(HTTPProxy)
(*in).DeepCopyInto(*out)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceKindPort.
func (in *WorkspaceKindPort) DeepCopy() *WorkspaceKindPort {
if in == nil {
return nil
}
out := new(WorkspaceKindPort)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *WorkspaceKindProbes) DeepCopyInto(out *WorkspaceKindProbes) {
*out = *in

View File

@ -2275,51 +2275,6 @@ spec:
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
httpProxy:
description: http proxy configs (MUTABLE)
properties:
removePathPrefix:
default: false
description: |-
if the path prefix is stripped from incoming HTTP requests
- if true, the '/workspace/{profile_name}/{workspace_name}/' path prefix
is stripped from incoming requests, the application sees the request
as if it was made to '/...'
- this only works if the application serves RELATIVE URLs for its assets
type: boolean
requestHeaders:
description: |-
header manipulation rules for incoming HTTP requests
- sets the `spec.http[].headers.request` of the Istio VirtualService
https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers-HeaderOperations
- the following string templates are available:
- `.PathPrefix`: the path prefix of the Workspace (e.g. '/workspace/{profile_name}/{workspace_name}/')
properties:
add:
additionalProperties:
type: string
description: append the given values to the headers specified
by keys (will create a comma-separated list of values)
example:
My-Header: value-to-append
type: object
remove:
description: remove the specified headers
example:
- Header-To-Remove
items:
type: string
type: array
set:
additionalProperties:
type: string
description: overwrite the headers specified by key with
the given values
example:
X-RStudio-Root-Path: '{{ .PathPrefix }}'
type: object
type: object
type: object
options:
description: options are the user-selectable fields, they determine
the PodSpec of the Workspace
@ -2457,7 +2412,6 @@ spec:
properties:
displayName:
description: the display name of the port
example: JupyterLab
maxLength: 64
minLength: 2
type: string
@ -2477,17 +2431,9 @@ spec:
maximum: 65535
minimum: 1
type: integer
protocol:
description: the protocol of the port
enum:
- HTTP
example: HTTP
type: string
required:
- displayName
- id
- port
- protocol
type: object
minItems: 1
type: array
@ -3729,6 +3675,87 @@ spec:
description: labels to be applied to the Pod resource
type: object
type: object
ports:
description: ports that the container listens on
items:
properties:
displayName:
description: the display name of the port
example: JupyterLab
maxLength: 64
minLength: 2
type: string
httpProxy:
description: the http proxy config for the port (MUTABLE)
properties:
removePathPrefix:
default: false
description: |-
if the path prefix is stripped from incoming HTTP requests
- if true, the '/workspace/{profile_name}/{workspace_name}/' path prefix
is stripped from incoming requests, the application sees the request
as if it was made to '/...'
- this only works if the application serves RELATIVE URLs for its assets
type: boolean
requestHeaders:
description: |-
header manipulation rules for incoming HTTP requests
- sets the `spec.http[].headers.request` of the Istio VirtualService
https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers-HeaderOperations
- the following string templates are available:
- `.PathPrefix`: the path prefix of the Workspace (e.g. '/workspace/{profile_name}/{workspace_name}/')
properties:
add:
additionalProperties:
type: string
description: append the given values to the headers
specified by keys (will create a comma-separated
list of values)
example:
My-Header: value-to-append
type: object
remove:
description: remove the specified headers
example:
- Header-To-Remove
items:
type: string
type: array
set:
additionalProperties:
type: string
description: overwrite the headers specified by
key with the given values
example:
X-RStudio-Root-Path: '{{ .PathPrefix }}'
type: object
type: object
type: object
id:
description: |-
the id of the port
- identifier for the port in `imageconfig` ports.[].id
example: jupyterlab
maxLength: 32
minLength: 1
pattern: ^[a-z0-9][a-z0-9_-]*[a-z0-9]$
type: string
protocol:
description: the protocol of the port
enum:
- HTTP
example: HTTP
type: string
required:
- displayName
- id
- protocol
type: object
minItems: 1
type: array
x-kubernetes-list-map-keys:
- id
x-kubernetes-list-type: map
probes:
description: standard probes to determine Container health (MUTABLE)
properties:

View File

@ -135,28 +135,38 @@ spec:
##
home: "/home/jovyan"
## http proxy configs (MUTABLE)
##
httpProxy:
## port configs (MUTABLE)
ports:
## if the path prefix is stripped from incoming HTTP requests
## - if true, the '/workspace/{profile_name}/{workspace_name}/' path prefix
## is stripped from incoming requests, the application sees the request
## as if it was made to '/...'
## - this only works if the application serves RELATIVE URLs for its assets
##
removePathPrefix: false
## the list of ports that the Workspace exposes
## configs apply to a single port
## portId is the identifier for the port in `imageconfig` ports.[].id
- id: "jupyterlab"
displayName: "JupyterLab"
protocol: "HTTP"
## header manipulation rules for incoming HTTP requests
## - sets the `spec.http[].headers.request` of the Istio VirtualService
## https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers-HeaderOperations
## - the following string templates are available:
## - `.PathPrefix`: the path prefix of the Workspace (e.g. '/workspace/{profile_name}/{workspace_name}/')
##
requestHeaders: {}
#set: { "X-RStudio-Root-Path": "{{ .PathPrefix }}" } # for RStudio
#add: {}
#remove: []
## http proxy configs (MUTABLE)
## only "HTTP" protocol ports are supported
httpProxy:
## if the path prefix is stripped from incoming HTTP requests
## - if true, the '/workspace/{profile_name}/{workspace_name}/' path prefix
## is stripped from incoming requests, the application sees the request
## as if it was made to '/...'
## - this only works if the application serves RELATIVE URLs for its assets
##
removePathPrefix: false
## header manipulation rules for incoming HTTP requests
## - sets the `spec.http[].headers.request` of the Istio VirtualService
## https://istio.io/latest/docs/reference/config/networking/virtual-service/#Headers-HeaderOperations
## - the following string templates are available:
## - `.PathPrefix`: the path prefix of the Workspace (e.g. '/workspace/{profile_name}/{workspace_name}/')
##
requestHeaders: {}
#set: { "X-RStudio-Root-Path": "{{ .PathPrefix }}" } # for RStudio
#add: {}
#remove: []
## environment variables for Workspace Pods (MUTABLE)
## - spec for EnvVar:
@ -286,9 +296,8 @@ spec:
##
ports:
- id: "jupyterlab"
displayName: "JupyterLab"
port: 8888
protocol: "HTTP"
## ================================
## EXAMPLE 2: a visible option
@ -305,9 +314,8 @@ spec:
imagePullPolicy: "IfNotPresent"
ports:
- id: "jupyterlab"
displayName: "JupyterLab"
port: 8888
protocol: "HTTP"
## ============================================================
## POD CONFIG OPTIONS

View File

@ -220,12 +220,19 @@ func NewExampleWorkspaceKind1(name string) *kubefloworgv1beta1.WorkspaceKind {
VolumeMounts: kubefloworgv1beta1.WorkspaceKindVolumeMounts{
Home: "/home/jovyan",
},
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{"X-RStudio-Root-Path": "{{ .PathPrefix }}"},
Add: map[string]string{},
Remove: []string{},
Ports: []kubefloworgv1beta1.WorkspaceKindPort{
{
Id: "jupyterlab",
DefaultDisplayName: "JupyterLab",
Protocol: "HTTP",
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{},
Add: map[string]string{},
Remove: []string{},
},
},
},
},
ExtraEnv: []v1.EnvVar{
@ -292,9 +299,8 @@ func NewExampleWorkspaceKind1(name string) *kubefloworgv1beta1.WorkspaceKind {
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
DisplayName: ptr.To("JupyterLab"),
Port: 8888,
Protocol: "HTTP",
},
},
},
@ -316,10 +322,8 @@ func NewExampleWorkspaceKind1(name string) *kubefloworgv1beta1.WorkspaceKind {
Image: "ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0",
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
Port: 8888,
Protocol: "HTTP",
Id: "jupyterlab",
Port: 8888,
},
},
},

View File

@ -625,8 +625,8 @@ func generateStatefulSet(workspace *kubefloworgv1beta1.Workspace, workspaceKind
// define go string template functions
// NOTE: these are used in places like the `extraEnv` values
containerPortsIdMap := make(map[string]kubefloworgv1beta1.ImagePort)
httpPathPrefixFunc := func(portId string) string {
containerPortsIdMap := make(map[kubefloworgv1beta1.PortId]kubefloworgv1beta1.ImagePort)
httpPathPrefixFunc := func(portId kubefloworgv1beta1.PortId) string {
port, ok := containerPortsIdMap[portId]
if ok {
return fmt.Sprintf("/workspace/%s/%s/%s/", workspace.Namespace, workspace.Name, port.Id)

View File

@ -20,10 +20,12 @@ import (
"bytes"
"fmt"
"text/template"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
)
// RenderExtraEnvValueTemplate renders a single WorkspaceKind `spec.podTemplate.extraEnv[].value` string template
func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(string) string) (string, error) {
func RenderExtraEnvValueTemplate(rawValue string, httpPathPrefixFunc func(kubefloworgv1beta1.PortId) string) (string, error) {
// Parse the raw value as a template
tmpl, err := template.New("value").

View File

@ -208,12 +208,24 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
VolumeMounts: kubefloworgv1beta1.WorkspaceKindVolumeMounts{
Home: "/home/jovyan",
},
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{"X-RStudio-Root-Path": "{{ .PathPrefix }}"},
Add: map[string]string{},
Remove: []string{},
Ports: []kubefloworgv1beta1.WorkspaceKindPort{
{
Id: "jupyterlab",
DefaultDisplayName: "JupyterLab",
Protocol: "HTTP",
HTTPProxy: &kubefloworgv1beta1.HTTPProxy{
RemovePathPrefix: ptr.To(false),
RequestHeaders: &kubefloworgv1beta1.IstioHeaderOperations{
Set: map[string]string{},
Add: map[string]string{},
Remove: []string{},
},
},
},
{
Id: "my_port",
DefaultDisplayName: "My Port",
Protocol: "HTTP",
},
},
ExtraEnv: []v1.EnvVar{
@ -280,9 +292,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
DisplayName: ptr.To("JupyterLab"),
Port: 8888,
Protocol: "HTTP",
},
},
},
@ -304,10 +315,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Image: "ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0",
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
Port: 8888,
Protocol: "HTTP",
Id: "jupyterlab",
Port: 8888,
},
},
},
@ -326,9 +335,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "my_port",
DisplayName: "something",
DisplayName: ptr.To("something"),
Port: 1234,
Protocol: "HTTP",
},
},
},
@ -346,10 +354,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Image: "redirect-test:step-2",
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "my_port",
DisplayName: "something",
Port: 1234,
Protocol: "HTTP",
Id: "my_port",
Port: 1234,
},
},
},
@ -364,10 +370,8 @@ func NewExampleWorkspaceKind(name string) *kubefloworgv1beta1.WorkspaceKind {
Image: "redirect-test:step-3",
Ports: []kubefloworgv1beta1.ImagePort{
{
Id: "my_port",
DisplayName: "something",
Port: 1234,
Protocol: "HTTP",
Id: "my_port",
Port: 1234,
},
},
},
@ -599,15 +603,48 @@ func NewExampleWorkspaceKindWithDuplicatePorts(name string) *kubefloworgv1beta1.
workspaceKind.Spec.PodTemplate.Options.ImageConfig.Values[0].Spec.Ports = []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
DisplayName: ptr.To("JupyterLab"),
Port: 8888,
Protocol: "HTTP",
},
{
Id: "jupyterlab2",
DisplayName: "JupyterLab2",
DisplayName: ptr.To("JupyterLab2"),
Port: 8888,
Protocol: "HTTP",
},
}
return workspaceKind
}
// NewExampleWorkspaceKindWithEmptyPortsArrayInPodTemplate returns a WorkspaceKind with an empty ports array in podTemplate.ports.
func NewExampleWorkspaceKindWithEmptyPortsArrayInPodTemplate(name string) *kubefloworgv1beta1.WorkspaceKind {
workspaceKind := NewExampleWorkspaceKind(name)
workspaceKind.Spec.PodTemplate.Ports = []kubefloworgv1beta1.WorkspaceKindPort{}
return workspaceKind
}
// NewExampleWorkspaceKindWithDuplicatePortsInPodTemplate returns a WorkspaceKind with duplicate ports in podTemplate.ports.
func NewExampleWorkspaceKindWithDuplicatePortsInPodTemplate(name string) *kubefloworgv1beta1.WorkspaceKind {
workspaceKind := NewExampleWorkspaceKind(name)
workspaceKind.Spec.PodTemplate.Ports = []kubefloworgv1beta1.WorkspaceKindPort{
{
Id: "jupyterlab",
DefaultDisplayName: "JupyterLab",
},
{
Id: "jupyterlab",
DefaultDisplayName: "JupyterLab",
},
}
return workspaceKind
}
// NewExampleWorkspaceKindWithNonExistentPortIdInImageConfig returns a WorkspaceKind with a non-existent portId in imageConfig.ports.
func NewExampleWorkspaceKindWithNonExistentPortIdInImageConfig(name string) *kubefloworgv1beta1.WorkspaceKind {
workspaceKind := NewExampleWorkspaceKind(name)
workspaceKind.Spec.PodTemplate.Ports = []kubefloworgv1beta1.WorkspaceKindPort{
{
Id: "non-existent-port-id",
DefaultDisplayName: "Non Existent Port",
},
}
return workspaceKind

View File

@ -98,6 +98,12 @@ func (v *WorkspaceKindValidator) ValidateCreate(ctx context.Context, obj runtime
}
}
// generate helper maps for podtemplate ports
podTemplatePortsIdMap := make(map[kubefloworgv1beta1.PortId]kubefloworgv1beta1.WorkspaceKindPort)
for _, port := range workspaceKind.Spec.PodTemplate.Ports {
podTemplatePortsIdMap[port.Id] = port
}
// validate default options
allErrs = append(allErrs, validateDefaultImageConfig(workspaceKind, imageConfigIdMap)...)
allErrs = append(allErrs, validateDefaultPodConfig(workspaceKind, podConfigIdMap)...)
@ -106,7 +112,7 @@ func (v *WorkspaceKindValidator) ValidateCreate(ctx context.Context, obj runtime
for _, imageConfigValue := range imageConfigIdMap {
imageConfigValueId := imageConfigValue.Id
imageConfigValuePath := field.NewPath("spec", "podTemplate", "options", "imageConfig", "values").Key(imageConfigValueId)
allErrs = append(allErrs, validateImageConfigValue(&imageConfigValue, imageConfigValuePath)...)
allErrs = append(allErrs, validateImageConfigValue(&imageConfigValue, imageConfigValuePath, podTemplatePortsIdMap)...)
}
// validate redirects
@ -156,6 +162,15 @@ func (v *WorkspaceKindValidator) ValidateUpdate(ctx context.Context, oldObj, new
allErrs = append(allErrs, validateExtraEnv(newWorkspaceKind)...)
}
// if the ports config changed, we need to validate all image config values again
shouldValidateAllImageConfigValues := !equality.Semantic.DeepEqual(newWorkspaceKind.Spec.PodTemplate.Ports, oldWorkspaceKind.Spec.PodTemplate.Ports)
// generate helper maps for podtemplate ports
podTemplatePortsIdMap := make(map[kubefloworgv1beta1.PortId]kubefloworgv1beta1.WorkspaceKindPort)
for _, port := range newWorkspaceKind.Spec.PodTemplate.Ports {
podTemplatePortsIdMap[port.Id] = port
}
// calculate changes to imageConfig values
var shouldValidateImageConfigRedirects = false
toValidateImageConfigIds := make(map[string]bool)
@ -173,6 +188,11 @@ func (v *WorkspaceKindValidator) ValidateUpdate(ctx context.Context, oldObj, new
newImageConfigRedirectMap[imageConfigValue.Id] = imageConfigValue.Redirect.To
}
// if ports changed, we need to validate all image config values
if shouldValidateAllImageConfigValues {
toValidateImageConfigIds[imageConfigValue.Id] = true
}
// check if the imageConfig value is new
if _, exists := oldImageConfigIdMap[imageConfigValue.Id]; !exists {
// we need to validate this imageConfig value since it is new
@ -181,6 +201,7 @@ func (v *WorkspaceKindValidator) ValidateUpdate(ctx context.Context, oldObj, new
// we always need to validate the imageConfig redirects if an imageConfig value was added
// because the new imageConfig value could be used by a redirect or cause a cycle
shouldValidateImageConfigRedirects = true
} else {
// if we haven't already decided to validate the imageConfig redirects,
// check if the redirect has changed
@ -331,7 +352,7 @@ func (v *WorkspaceKindValidator) ValidateUpdate(ctx context.Context, oldObj, new
for imageConfigValueId := range toValidateImageConfigIds {
imageConfigValue := newImageConfigIdMap[imageConfigValueId]
imageConfigValuePath := field.NewPath("spec", "podTemplate", "options", "imageConfig", "values").Key(imageConfigValueId)
allErrs = append(allErrs, validateImageConfigValue(&imageConfigValue, imageConfigValuePath)...)
allErrs = append(allErrs, validateImageConfigValue(&imageConfigValue, imageConfigValuePath, podTemplatePortsIdMap)...)
}
// process bad imageConfig values
@ -521,7 +542,7 @@ func validateExtraEnv(workspaceKind *kubefloworgv1beta1.WorkspaceKind) []*field.
var errs []*field.Error
// the real httpPathPrefix can't fail, so we return a dummy value
httpPathPrefixFunc := func(portId string) string {
httpPathPrefixFunc := func(portId kubefloworgv1beta1.PortId) string {
return "DUMMY_HTTP_PATH_PREFIX"
}
@ -569,19 +590,32 @@ func validateDefaultPodConfig(workspaceKind *kubefloworgv1beta1.WorkspaceKind, p
}
// validateImageConfigValue validates an imageConfig value
func validateImageConfigValue(imageConfigValue *kubefloworgv1beta1.ImageConfigValue, imageConfigValuePath *field.Path) []*field.Error {
func validateImageConfigValue(imageConfigValue *kubefloworgv1beta1.ImageConfigValue, imageConfigValuePath *field.Path, podTemplatePortsIdMap map[kubefloworgv1beta1.PortId]kubefloworgv1beta1.WorkspaceKindPort) []*field.Error {
var errs []*field.Error
// validate the ports
seenPorts := make(map[int32]bool)
for _, port := range imageConfigValue.Spec.Ports {
portId := port.Id
portId := string(port.Id)
portNumber := port.Port
if _, exists := seenPorts[portNumber]; exists {
portPath := imageConfigValuePath.Child("spec", "ports").Key(portId).Child("port")
errs = append(errs, field.Invalid(portPath, portNumber, fmt.Sprintf("port %d is defined more than once", portNumber)))
}
seenPorts[portNumber] = true
// validate that the port ID exists in podTemplate.ports
if _, exists := podTemplatePortsIdMap[port.Id]; !exists {
portIdPath := imageConfigValuePath.Child("spec", "ports").Key(portId).Child("id")
errs = append(errs, field.Invalid(portIdPath, port.Id, "missing from spec.podTemplate.ports"))
} else {
// validate HTTPProxy is only set if protocol is HTTP
podTemplatePort := podTemplatePortsIdMap[port.Id]
if podTemplatePort.HTTPProxy != nil && podTemplatePort.Protocol != kubefloworgv1beta1.ImagePortProtocolHTTP {
httpProxyPath := imageConfigValuePath.Child("spec", "ports").Key(portId).Child("httpProxy")
errs = append(errs, field.Invalid(httpProxyPath, podTemplatePort.HTTPProxy, "httpProxy can only be set when protocol is HTTP"))
}
}
}
return errs

View File

@ -25,6 +25,7 @@ import (
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
"sigs.k8s.io/controller-runtime/pkg/client"
kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1"
@ -98,6 +99,21 @@ var _ = Describe("WorkspaceKind Webhook", func() {
workspaceKind: NewExampleWorkspaceKindWithDuplicatePorts("wsk-webhook-create--image-config-duplicate-ports"),
shouldSucceed: false,
},
{
description: "should reject creation with empty ports array in podTemplate",
workspaceKind: NewExampleWorkspaceKindWithEmptyPortsArrayInPodTemplate("wsk-webhook-create--pod-template-empty-ports-array"),
shouldSucceed: false,
},
{
description: "should reject creation with duplicate ports in podTemplate.ports",
workspaceKind: NewExampleWorkspaceKindWithDuplicatePortsInPodTemplate("wsk-webhook-create--pod-template-duplicate-portids"),
shouldSucceed: false,
},
{
description: "should reject creation with non-existent portId in imageConfig.ports",
workspaceKind: NewExampleWorkspaceKindWithNonExistentPortIdInImageConfig("wsk-webhook-create--image-config-non-existent-portid"),
shouldSucceed: false,
},
{
description: "should reject creation if extraEnv[].value is not a valid Go template",
workspaceKind: NewExampleWorkspaceKindWithInvalidExtraEnvValue("wsk-webhook-create--extra-invalid-env-value"),
@ -494,20 +510,39 @@ var _ = Describe("WorkspaceKind Webhook", func() {
wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Spec.Ports = []kubefloworgv1beta1.ImagePort{
{
Id: "jupyterlab",
DisplayName: "JupyterLab",
DisplayName: ptr.To("JupyterLab"),
Port: duplicatePortNumber,
Protocol: "HTTP",
},
{
Id: "jupyterlab2",
DisplayName: "JupyterLab2",
DisplayName: ptr.To("JupyterLab2"),
Port: duplicatePortNumber,
Protocol: "HTTP",
},
}
return ContainSubstring("port %d is defined more than once", duplicatePortNumber)
},
},
{
description: "should reject updating a portId in podTemplate.ports to a duplicate portId",
shouldSucceed: false,
workspaceKind: NewExampleWorkspaceKind(workspaceKindName),
modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher {
wsk.Spec.PodTemplate.Ports[1].Id = "jupyterlab"
return And(ContainSubstring("Duplicate value:"), ContainSubstring("jupyterlab"))
},
},
{
description: "should reject updating a portId in podTemplate.ports to a non-existent portId in imageConfig.ports",
shouldSucceed: false,
workspaceKind: NewExampleWorkspaceKind(workspaceKindName),
modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher {
existingPortId := wsk.Spec.PodTemplate.Ports[0].Id
wsk.Spec.PodTemplate.Ports[0].Id = "non-existent-port-id"
return ContainSubstring("%q: missing from spec.podTemplate.ports", existingPortId)
},
},
{
description: "should reject updating a podMetadata.labels key to an invalid value",
shouldSucceed: false,