From f39f3a8db65bd4cc3550592b65de71ef1cd8e7d8 Mon Sep 17 00:00:00 2001 From: Priyansh Saxena <130545865+Transcendental-Programmer@users.noreply.github.com> Date: Thu, 7 Aug 2025 18:12:25 +0000 Subject: [PATCH] feat: implement validation webhook for serviceexport objects - Add validation webhook to check slice existence on cluster - Validate namespace is in onboarded application namespaces - Implement unified webhook handler for routing requests - Add comprehensive test coverage for all validation scenarios - Update serviceexport types with proper webhook annotations Fixes #358 Signed-off-by: GitHub Copilot Signed-off-by: Priyansh Saxena <130545865+Transcendental-Programmer@users.noreply.github.com> --- api/v1beta1/serviceexport_types.go | 17 +++ .../samples/mesh_v1beta1_serviceexport.yaml | 14 +- main.go | 30 ++-- pkg/webhook/serviceexport/webhook.go | 85 ++++++++++++ pkg/webhook/serviceexport/webhook_test.go | 128 ++++++++++++++++++ pkg/webhook/unified.go | 42 ++++++ 6 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 pkg/webhook/serviceexport/webhook.go create mode 100644 pkg/webhook/serviceexport/webhook_test.go create mode 100644 pkg/webhook/unified.go diff --git a/api/v1beta1/serviceexport_types.go b/api/v1beta1/serviceexport_types.go index 1cab8103c..bb7ca9264 100644 --- a/api/v1beta1/serviceexport_types.go +++ b/api/v1beta1/serviceexport_types.go @@ -21,6 +21,7 @@ package v1beta1 import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" gatewayapi "sigs.k8s.io/gateway-api/apis/v1" ) @@ -136,6 +137,7 @@ type ServiceExportStatus struct { // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.exportStatus` // +kubebuilder:printcolumn:name="Alias",type=string,JSONPath=`.spec.aliases` // +kubebuilder:resource:path=serviceexports,singular=serviceexport,shortName=svcex +// +kubebuilder:webhook:path=/validate-webhook,mutating=false,failurePolicy=fail,sideEffects=None,groups=networking.kubeslice.io,resources=serviceexports,verbs=create;update,versions=v1beta1,name=vserviceexport.networking.kubeslice.io,admissionReviewVersions=v1 // ServiceExport is the Schema for the serviceexports API type ServiceExport struct { @@ -158,3 +160,18 @@ type ServiceExportList struct { func init() { SchemeBuilder.Register(&ServiceExport{}, &ServiceExportList{}) } + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *ServiceExport) ValidateCreate() error { + return nil +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *ServiceExport) ValidateUpdate(old runtime.Object) error { + return nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *ServiceExport) ValidateDelete() error { + return nil +} diff --git a/config/samples/mesh_v1beta1_serviceexport.yaml b/config/samples/mesh_v1beta1_serviceexport.yaml index c07e169cc..c2936b828 100644 --- a/config/samples/mesh_v1beta1_serviceexport.yaml +++ b/config/samples/mesh_v1beta1_serviceexport.yaml @@ -1,7 +1,15 @@ -apiVersion: mesh.avesha.io/v1beta1 +apiVersion: networking.kubeslice.io/v1beta1 kind: ServiceExport metadata: name: serviceexport-sample + namespace: app-namespace spec: - # Add fields here - foo: bar + slice: test-slice + selector: + matchLabels: + app: nginx + ports: + - name: http + containerPort: 80 + protocol: TCP + servicePort: 80 diff --git a/main.go b/main.go index a95f367e9..922deef95 100644 --- a/main.go +++ b/main.go @@ -71,6 +71,8 @@ import ( "github.com/kubeslice/worker-operator/pkg/networkpolicy" "github.com/kubeslice/worker-operator/pkg/utils" podwh "github.com/kubeslice/worker-operator/pkg/webhook/pod" + svcexportwh "github.com/kubeslice/worker-operator/pkg/webhook/serviceexport" + unified "github.com/kubeslice/worker-operator/pkg/webhook" //+kubebuilder:scaffold:imports ) @@ -133,18 +135,30 @@ func main() { // Use an environment variable to be able to disable webhooks, so that we can run the operator locally if utils.GetEnvOrDefault("ENABLE_WEBHOOKS", "true") == "true" { + podWebhook := &podwh.WebhookServer{ + Client: mgr.GetClient(), + SliceInfoClient: podwh.NewWebhookClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + } + serviceExportWebhook := &svcexportwh.ServiceExportValidator{ + Client: mgr.GetClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + } + mgr.GetWebhookServer().Register("/mutate-webhook", &webhook.Admission{ - Handler: &podwh.WebhookServer{ - Client: mgr.GetClient(), - SliceInfoClient: podwh.NewWebhookClient(), - Decoder: admission.NewDecoder(mgr.GetScheme()), + Handler: &unified.UnifiedWebhookHandler{ + Client: mgr.GetClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + PodWebhook: podWebhook, + ServiceExportWebhook: serviceExportWebhook, }, }) mgr.GetWebhookServer().Register("/validate-webhook", &webhook.Admission{ - Handler: &podwh.WebhookServer{ - Client: mgr.GetClient(), - SliceInfoClient: podwh.NewWebhookClient(), - Decoder: admission.NewDecoder(mgr.GetScheme()), + Handler: &unified.UnifiedWebhookHandler{ + Client: mgr.GetClient(), + Decoder: admission.NewDecoder(mgr.GetScheme()), + PodWebhook: podWebhook, + ServiceExportWebhook: serviceExportWebhook, }, }) } diff --git a/pkg/webhook/serviceexport/webhook.go b/pkg/webhook/serviceexport/webhook.go new file mode 100644 index 000000000..d5ebdba54 --- /dev/null +++ b/pkg/webhook/serviceexport/webhook.go @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 serviceexport + +import ( + "context" + "fmt" + "net/http" + + "github.com/kubeslice/worker-operator/api/v1beta1" + "github.com/kubeslice/worker-operator/pkg/logger" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +var log = logger.NewWrappedLogger().WithName("ServiceExportWebhook") + +type ServiceExportValidator struct { + Client client.Client + Decoder *admission.Decoder +} + +func (v *ServiceExportValidator) Handle(ctx context.Context, req admission.Request) admission.Response { + svcExport := &v1beta1.ServiceExport{} + err := v.Decoder.Decode(req, svcExport) + if err != nil { + return admission.Errored(http.StatusBadRequest, err) + } + + if req.Operation == "CREATE" || req.Operation == "UPDATE" { + if err := v.validateServiceExport(ctx, svcExport, req.Namespace); err != nil { + return admission.Denied(err.Error()) + } + } + + return admission.Allowed("") +} + +func (v *ServiceExportValidator) validateServiceExport(ctx context.Context, svcExport *v1beta1.ServiceExport, namespace string) error { + sliceName := svcExport.Spec.Slice + if sliceName == "" { + return fmt.Errorf("slice name is required") + } + + slice := &v1beta1.Slice{} + sliceKey := client.ObjectKey{Name: sliceName, Namespace: namespace} + if err := v.Client.Get(ctx, sliceKey, slice); err != nil { + return fmt.Errorf("slice '%s' not found on cluster: %v", sliceName, err) + } + + if slice.Status.SliceConfig == nil || slice.Status.SliceConfig.NamespaceIsolationProfile == nil { + return fmt.Errorf("slice '%s' is not properly configured", sliceName) + } + + appNamespaces := slice.Status.SliceConfig.NamespaceIsolationProfile.ApplicationNamespaces + namespaceFound := false + for _, appNs := range appNamespaces { + if appNs == namespace { + namespaceFound = true + break + } + } + + if !namespaceFound { + return fmt.Errorf("namespace '%s' is not an onboarded application namespace for slice '%s'", namespace, sliceName) + } + + return nil +} diff --git a/pkg/webhook/serviceexport/webhook_test.go b/pkg/webhook/serviceexport/webhook_test.go new file mode 100644 index 000000000..0fefef191 --- /dev/null +++ b/pkg/webhook/serviceexport/webhook_test.go @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 serviceexport + +import ( + "context" + "strings" + "testing" + + "github.com/kubeslice/worker-operator/api/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestValidateServiceExport(t *testing.T) { + scheme := runtime.NewScheme() + _ = v1beta1.AddToScheme(scheme) + + tests := []struct { + name string + svcExport *v1beta1.ServiceExport + slice *v1beta1.Slice + namespace string + shouldError bool + errorMsg string + }{ + { + name: "valid serviceexport", + svcExport: &v1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "app-ns"}, + Spec: v1beta1.ServiceExportSpec{ + Slice: "test-slice", + }, + }, + slice: &v1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{Name: "test-slice", Namespace: "app-ns"}, + Status: v1beta1.SliceStatus{ + SliceConfig: &v1beta1.SliceConfig{ + NamespaceIsolationProfile: &v1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{"app-ns"}, + }, + }, + }, + }, + namespace: "app-ns", + shouldError: false, + }, + { + name: "slice not found", + svcExport: &v1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "app-ns"}, + Spec: v1beta1.ServiceExportSpec{ + Slice: "nonexistent-slice", + }, + }, + namespace: "app-ns", + shouldError: true, + errorMsg: "slice 'nonexistent-slice' not found on cluster", + }, + { + name: "namespace not in application namespaces", + svcExport: &v1beta1.ServiceExport{ + ObjectMeta: metav1.ObjectMeta{Name: "test-svc", Namespace: "wrong-ns"}, + Spec: v1beta1.ServiceExportSpec{ + Slice: "test-slice", + }, + }, + slice: &v1beta1.Slice{ + ObjectMeta: metav1.ObjectMeta{Name: "test-slice", Namespace: "wrong-ns"}, + Status: v1beta1.SliceStatus{ + SliceConfig: &v1beta1.SliceConfig{ + NamespaceIsolationProfile: &v1beta1.NamespaceIsolationProfile{ + ApplicationNamespaces: []string{"app-ns"}, + }, + }, + }, + }, + namespace: "wrong-ns", + shouldError: true, + errorMsg: "namespace 'wrong-ns' is not an onboarded application namespace for slice 'test-slice'", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var objs []runtime.Object + if tt.slice != nil { + objs = append(objs, tt.slice) + } + + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(objs...).Build() + validator := &ServiceExportValidator{ + Client: fakeClient, + } + + err := validator.validateServiceExport(context.Background(), tt.svcExport, tt.namespace) + + if tt.shouldError { + if err == nil { + t.Errorf("expected error but got none") + } else if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) { + t.Errorf("expected error message to contain '%s' but got '%s'", tt.errorMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + } + }) + } +} diff --git a/pkg/webhook/unified.go b/pkg/webhook/unified.go new file mode 100644 index 000000000..5a18df50a --- /dev/null +++ b/pkg/webhook/unified.go @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Avesha, Inc. All rights reserved. + * + * SPDX-License-Identifier: Apache-2.0 + * + * 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 ( + "context" + + podwh "github.com/kubeslice/worker-operator/pkg/webhook/pod" + svcexportwh "github.com/kubeslice/worker-operator/pkg/webhook/serviceexport" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type UnifiedWebhookHandler struct { + Client client.Client + Decoder *admission.Decoder + PodWebhook *podwh.WebhookServer + ServiceExportWebhook *svcexportwh.ServiceExportValidator +} + +func (h *UnifiedWebhookHandler) Handle(ctx context.Context, req admission.Request) admission.Response { + if req.Kind.Group == "networking.kubeslice.io" && req.Kind.Kind == "ServiceExport" { + return h.ServiceExportWebhook.Handle(ctx, req) + } + return h.PodWebhook.Handle(ctx, req) +}