/* Copyright 2024. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ package webhook import ( "slices" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" gomegaTypes "github.com/onsi/gomega/types" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" kubefloworgv1beta1 "github.com/kubeflow/notebooks/workspaces/controller/api/v1beta1" ) var _ = Describe("WorkspaceKind Webhook", func() { const ( namespaceName = "default" ) Context("When creating a WorkspaceKind", Ordered, func() { testCases := []struct { // the "Should()" description of the test description string // the WorkspaceKind to attempt to create workspaceKind *kubefloworgv1beta1.WorkspaceKind // if the test should succeed shouldSucceed bool }{ { description: "should accept creation of a valid WorkspaceKind", workspaceKind: NewExampleWorkspaceKind("wsk-webhook-create--valid"), shouldSucceed: true, }, { description: "should reject creation with cycle in imageConfig redirects", workspaceKind: NewExampleWorkspaceKindWithImageConfigCycle("wsk-webhook-create--image-config-cycle"), shouldSucceed: false, }, { description: "should reject creation with cycle in podConfig redirects", workspaceKind: NewExampleWorkspaceKindWithPodConfigCycle("wsk-webhook-create--pod-config-cycle"), shouldSucceed: false, }, { description: "should reject creation with invalid redirect target in imageConfig options", workspaceKind: NewExampleWorkspaceKindWithInvalidImageConfigRedirect("wsk-webhook-create--image-config-invalid-redirect"), shouldSucceed: false, }, { description: "should reject creation with invalid redirect target in podConfig options", workspaceKind: NewExampleWorkspaceKindWithInvalidPodConfigRedirect("wsk-webhook-create--pod-config-invalid-redirect"), shouldSucceed: false, }, { description: "should reject creation with invalid default imageConfig", workspaceKind: NewExampleWorkspaceKindWithInvalidDefaultImageConfig("wsk-webhook-create--image-config-invalid-default"), shouldSucceed: false, }, { description: "should reject creation with invalid default podConfig", workspaceKind: NewExampleWorkspaceKindWithInvalidDefaultPodConfig("wsk-webhook-create--pod-config-invalid-default"), shouldSucceed: false, }, { description: "should reject creation with duplicate ports in imageConfig", workspaceKind: NewExampleWorkspaceKindWithDuplicatePorts("wsk-webhook-create--image-config-duplicate-ports"), shouldSucceed: false, }, { description: "should reject creation if extraEnv[].value is not a valid Go template", workspaceKind: NewExampleWorkspaceKindWithInvalidExtraEnvValue("wsk-webhook-create--extra-invalid-env-value"), shouldSucceed: false, }, } for _, tc := range testCases { It(tc.description, func() { if tc.shouldSucceed { By("creating the WorkspaceKind") Expect(k8sClient.Create(ctx, tc.workspaceKind)).To(Succeed()) By("deleting the WorkspaceKind") Expect(k8sClient.Delete(ctx, tc.workspaceKind)).To(Succeed()) } else { By("creating the WorkspaceKind") Expect(k8sClient.Create(ctx, tc.workspaceKind)).NotTo(Succeed()) } }) } }) Context("When updating a WorkspaceKind", Ordered, func() { const ( workspaceName = "wsk-webhook-update-test" workspaceKindName = "wsk-webhook-update-test" ) AfterEach(func() { By("deleting the Workspace, if it exists") workspace := &kubefloworgv1beta1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceName, Namespace: namespaceName, }, } _ = k8sClient.Delete(ctx, workspace) By("deleting the WorkspaceKind, if it exists") workspaceKind := &kubefloworgv1beta1.WorkspaceKind{ ObjectMeta: metav1.ObjectMeta{ Name: workspaceKindName, }, } _ = k8sClient.Delete(ctx, workspaceKind) }) testCases := []struct { // the "Should()" description of the test description string // if the test should succeed shouldSucceed bool // the initial state of the WorkspaceKind (required) workspaceKind *kubefloworgv1beta1.WorkspaceKind // the initial state of the Workspace, if any workspace *kubefloworgv1beta1.Workspace // modifyKindFn modifies the WorkspaceKind in some way. // returns a string matcher for the error message (only used if `shouldSucceed` is false) modifyKindFn func(*kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher }{ { description: "should accept re-ordering in-use imageConfig values", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { // reverse the imageConfig values list slices.Reverse(wsk.Spec.PodTemplate.Options.ImageConfig.Values) return ContainSubstring("") }, }, { description: "should accept re-ordering in-use podConfig values", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { // reverse the podConfig values list slices.Reverse(wsk.Spec.PodTemplate.Options.PodConfig.Values) return ContainSubstring("") }, }, { description: "should reject updates to in-use imageConfig spec", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { inUseId := wsk.Spec.PodTemplate.Options.ImageConfig.Values[0].Id wsk.Spec.PodTemplate.Options.ImageConfig.Values[0].Spec.Image = "new-image:latest" return ContainSubstring("imageConfig value %q is in use and cannot be changed", inUseId) }, }, { description: "should reject updates to in-use podConfig spec", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { inUseId := wsk.Spec.PodTemplate.Options.PodConfig.Values[0].Id wsk.Spec.PodTemplate.Options.PodConfig.Values[0].Spec.Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("1.5"), }, } return ContainSubstring("podConfig value %q is in use and cannot be changed", inUseId) }, }, { description: "should accept updates to unused imageConfig spec", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Spec.Image = "new-image:latest" return ContainSubstring("") }, }, { description: "should accept updates to unused podConfig spec", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { wsk.Spec.PodTemplate.Options.PodConfig.Values[1].Spec.Resources = &corev1.ResourceRequirements{ Limits: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("1.5"), }, } return ContainSubstring("") }, }, { description: "should reject removing in-use imageConfig values", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "jupyterlab_scipy_180" newValues := make([]kubefloworgv1beta1.ImageConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.ImageConfig.Values = newValues return ContainSubstring("imageConfig value %q is in use and cannot be removed", toBeRemoved) }, }, { description: "should reject removing in-use podConfig values", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "tiny_cpu" newValues := make([]kubefloworgv1beta1.PodConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.PodConfig.Values = newValues return ContainSubstring("podConfig value %q is in use and cannot be removed", toBeRemoved) }, }, { description: "should accept removing an unused imageConfig value", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "redirect_step_1" newValues := make([]kubefloworgv1beta1.ImageConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.ImageConfig.Values = newValues return ContainSubstring("") }, }, { description: "should accept removing an unused podConfig value", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "redirect_step_1" newValues := make([]kubefloworgv1beta1.PodConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.PodConfig.Values = newValues return ContainSubstring("") }, }, { description: "should reject removing the default imageConfig value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "jupyterlab_scipy_190" newValues := make([]kubefloworgv1beta1.ImageConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.ImageConfig.Values = newValues return ContainSubstring("default imageConfig %q not found", toBeRemoved) }, }, { description: "should reject removing the default podConfig value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), workspace: NewExampleWorkspace(workspaceName, namespaceName, workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "tiny_cpu" newValues := make([]kubefloworgv1beta1.PodConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.PodConfig.Values = newValues return ContainSubstring("default podConfig %q not found", toBeRemoved) }, }, { description: "should reject removing the target of an imageConfig redirect", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "redirect_step_2" newValues := make([]kubefloworgv1beta1.ImageConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.ImageConfig.Values = newValues return ContainSubstring("target imageConfig %q does not exist", toBeRemoved) }, }, { description: "should reject removing the target of a podConfig redirect", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := "redirect_step_2" newValues := make([]kubefloworgv1beta1.PodConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { if value.Id != toBeRemoved { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.PodConfig.Values = newValues return ContainSubstring("target podConfig %q does not exist", toBeRemoved) }, }, { description: "should accept removing an entire imageConfig redirect chain", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := map[string]bool{"redirect_step_1": true, "redirect_step_2": true, "redirect_step_3": true} newValues := make([]kubefloworgv1beta1.ImageConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.ImageConfig.Values { if !toBeRemoved[value.Id] { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.ImageConfig.Values = newValues return ContainSubstring("") }, }, { description: "should accept removing an entire podConfig redirect chain", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { toBeRemoved := map[string]bool{"redirect_step_1": true, "redirect_step_2": true, "redirect_step_3": true} newValues := make([]kubefloworgv1beta1.PodConfigValue, 0) for _, value := range wsk.Spec.PodTemplate.Options.PodConfig.Values { if !toBeRemoved[value.Id] { newValues = append(newValues, value) } } wsk.Spec.PodTemplate.Options.PodConfig.Values = newValues return ContainSubstring("") }, }, { description: "should reject updating an imageConfig value to create a self-cycle", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { valueId := wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Id wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Redirect = &kubefloworgv1beta1.OptionRedirect{To: valueId} return ContainSubstring("imageConfig redirect cycle detected: [%s]", valueId) }, }, { description: "should reject updating a podConfig value to create a 2-step cycle", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { step1 := wsk.Spec.PodTemplate.Options.PodConfig.Values[0].Id step2 := wsk.Spec.PodTemplate.Options.PodConfig.Values[1].Id wsk.Spec.PodTemplate.Options.PodConfig.Values[0].Redirect = &kubefloworgv1beta1.OptionRedirect{To: step2} wsk.Spec.PodTemplate.Options.PodConfig.Values[1].Redirect = &kubefloworgv1beta1.OptionRedirect{To: step1} return ContainSubstring("podConfig redirect cycle detected: [") // there is no guarantee on which element will be first }, }, { description: "should reject updating an imageConfig to redirect to a non-existent value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { invalidTarget := "invalid_image_config" wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Redirect = &kubefloworgv1beta1.OptionRedirect{To: invalidTarget} return ContainSubstring("target imageConfig %q does not exist", invalidTarget) }, }, { description: "should reject updating a podConfig to redirect to a non-existent value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { invalidTarget := "invalid_pod_config" wsk.Spec.PodTemplate.Options.PodConfig.Values[0].Redirect = &kubefloworgv1beta1.OptionRedirect{To: invalidTarget} return ContainSubstring("target podConfig %q does not exist", invalidTarget) }, }, { description: "should reject updating the default imageConfig value to a non-existent value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { invalidDefault := "invalid_image_config" wsk.Spec.PodTemplate.Options.ImageConfig.Spawner.Default = invalidDefault return ContainSubstring("default imageConfig %q not found", invalidDefault) }, }, { description: "should reject updating the default podConfig value to a non-existent value", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { invalidDefault := "invalid_pod_config" wsk.Spec.PodTemplate.Options.PodConfig.Spawner.Default = invalidDefault return ContainSubstring("default podConfig %q not found", invalidDefault) }, }, { description: "should reject updating an imageConfig to have duplicate ports", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { duplicatePortNumber := int32(8888) wsk.Spec.PodTemplate.Options.ImageConfig.Values[1].Spec.Ports = []kubefloworgv1beta1.ImagePort{ { Id: "jupyterlab", DisplayName: "JupyterLab", Port: duplicatePortNumber, Protocol: "HTTP", }, { Id: "jupyterlab2", DisplayName: "JupyterLab2", Port: duplicatePortNumber, Protocol: "HTTP", }, } return ContainSubstring("port %d is defined more than once", duplicatePortNumber) }, }, { description: "should reject updating an extraEnv[].value to an invalid Go template", shouldSucceed: false, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { invalidValue := `{{ httpPathPrefix "jupyterlab" }` wsk.Spec.PodTemplate.ExtraEnv[0].Value = invalidValue return ContainSubstring("failed to parse template %q", invalidValue) }, }, { description: "should accept updating an extraEnv[].value to a valid Go template", shouldSucceed: true, workspaceKind: NewExampleWorkspaceKind(workspaceKindName), modifyKindFn: func(wsk *kubefloworgv1beta1.WorkspaceKind) gomegaTypes.GomegaMatcher { wsk.Spec.PodTemplate.ExtraEnv[0].Value = `{{ httpPathPrefix "jupyterlab" }}` return ContainSubstring("") }, }, } for _, tc := range testCases { It(tc.description, func() { if tc.workspaceKind == nil { Fail("invalid test case definition: workspaceKind is required") } By("creating the WorkspaceKind") // NOTE: cleanup is handled in the AfterEach() Expect(k8sClient.Create(ctx, tc.workspaceKind)).To(Succeed()) By("retrieving the WorkspaceKind") workspaceKind := &kubefloworgv1beta1.WorkspaceKind{} Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(tc.workspaceKind), workspaceKind)).To(Succeed()) if tc.workspace != nil { By("creating the Workspace") // NOTE: cleanup is handled in the AfterEach() Expect(k8sClient.Create(ctx, tc.workspace)).To(Succeed()) By("retrieving the Workspace") workspace := &kubefloworgv1beta1.Workspace{} Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(tc.workspace), workspace)).To(Succeed()) } By("updating the WorkspaceKind") patch := client.MergeFrom(workspaceKind.DeepCopy()) modifiedWorkspaceKind := workspaceKind.DeepCopy() errMatcher := tc.modifyKindFn(modifiedWorkspaceKind) err := k8sClient.Patch(ctx, modifiedWorkspaceKind, patch) if tc.shouldSucceed { Expect(err).To(Succeed()) } else { Expect(err).NotTo(Succeed()) Expect(err.Error()).To(errMatcher) } }) } }) })