diff --git a/manifests/vanilla/validatingwebhook.yaml b/manifests/vanilla/validatingwebhook.yaml index 0179317606..5c0cacad4b 100644 --- a/manifests/vanilla/validatingwebhook.yaml +++ b/manifests/vanilla/validatingwebhook.yaml @@ -40,6 +40,33 @@ webhooks: operations: ["UPDATE", "DELETE"] resources: ["persistentvolumeclaims"] scope: "Namespaced" + - apiGroups: ["snapshot.storage.k8s.io"] + apiVersions: ["v1"] + operations: ["DELETE"] + resources: ["volumesnapshots"] + scope: "Namespaced" + sideEffects: None + admissionReviewVersions: ["v1"] + failurePolicy: Fail +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: MutatingWebhookConfiguration +metadata: + name: mutation.csi.vsphere.vmware.com +webhooks: + - name: mutation.csi.vsphere.vmware.com + clientConfig: + service: + name: vsphere-webhook-svc + namespace: vmware-system-csi + path: "/mutate" + caBundle: ${CA_BUNDLE} + rules: + - apiGroups: [""] + apiVersions: ["v1"] + operations: ["CREATE"] + resources: ["persistentvolumeclaims"] + scope: "Namespaced" sideEffects: None admissionReviewVersions: ["v1"] failurePolicy: Fail @@ -61,6 +88,9 @@ rules: - apiGroups: [""] resources: ["persistentvolumes"] verbs: ["get"] + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "update", "patch"] --- kind: ClusterRoleBinding apiVersion: rbac.authorization.k8s.io/v1 diff --git a/pkg/syncer/admissionhandler/admissionhandler.go b/pkg/syncer/admissionhandler/admissionhandler.go index 1fb47b104c..8060fad9b8 100644 --- a/pkg/syncer/admissionhandler/admissionhandler.go +++ b/pkg/syncer/admissionhandler/admissionhandler.go @@ -27,6 +27,7 @@ import ( "path/filepath" admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -35,6 +36,7 @@ import ( cnstypes "github.com/vmware/govmomi/cns/types" "k8s.io/apimachinery/pkg/runtime/serializer" cr_log "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" cnsconfig "sigs.k8s.io/vsphere-csi-driver/v3/pkg/common/config" "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common/commonco" @@ -239,6 +241,7 @@ func StartWebhookServer(ctx context.Context, enableWebhookClientCertVerification common.TopologyAwareFileVolume) featureFileVolumesWithVmServiceEnabled = containerOrchestratorUtility.IsFSSEnabled(ctx, common.FileVolumesWithVmService) + featureIsLinkedCloneSupportEnabled = containerOrchestratorUtility.IsFSSEnabled(ctx, common.LinkedCloneSupportFSS) if featureGateCsiMigrationEnabled || featureGateBlockVolumeSnapshotEnabled { certs, err := tls.LoadX509KeyPair(cfg.WebHookConfig.CertFile, cfg.WebHookConfig.KeyFile) @@ -266,6 +269,7 @@ func StartWebhookServer(ctx context.Context, enableWebhookClientCertVerification // Define http server and server handler. mux := http.NewServeMux() mux.HandleFunc("/validate", validationHandler) + mux.HandleFunc("/mutate", mutationHandler) server.Handler = mux // Start webhook server. @@ -354,6 +358,8 @@ func validationHandler(w http.ResponseWriter, r *http.Request) { admissionResponse = validatePVC(ctx, ar.Request) case "PersistentVolume": admissionResponse = validatePv(ctx, ar.Request) + case "VolumeSnapshot": + admissionResponse = validateVolumeSnapshot(ctx, ar.Request) default: log.Infof("Skipping validation for resource type: %q", ar.Request.Kind.Kind) admissionResponse = &admissionv1.AdmissionResponse{ @@ -383,3 +389,151 @@ func validationHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError) } } + +// mutationHandler is the handler for webhook http multiplexer to help +// mutate resources. Depending on the URL mutation of AdmissionReview +// will be redirected to appropriate mutation function. +func mutationHandler(w http.ResponseWriter, r *http.Request) { + var body []byte + ctx, log := logger.GetNewContextWithLogger() + if r.Body != nil { + if data, err := io.ReadAll(r.Body); err == nil { + body = data + } + } + if len(body) == 0 { + log.Error("received empty request body") + http.Error(w, "received empty request body", http.StatusBadRequest) + return + } + log.Debugf("Received mutation request") + // Verify the content type is accurate. + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + log.Errorf("content-Type=%s, expect application/json", contentType) + http.Error(w, "invalid Content-Type, expect `application/json`", http.StatusUnsupportedMediaType) + return + } + + var admissionResponse *admissionv1.AdmissionResponse + ar := admissionv1.AdmissionReview{} + codecs := serializer.NewCodecFactory(runtime.NewScheme()) + deserializer := codecs.UniversalDeserializer() + if _, _, err := deserializer.Decode(body, nil, &ar); err != nil { + log.Errorf("Can't decode body: %v", err) + admissionResponse = &admissionv1.AdmissionResponse{ + Result: &metav1.Status{ + Message: err.Error(), + }, + } + } else { + if r.URL.Path == "/mutate" { + log.Debugf("request URL path is /mutate") + log.Debugf("admissionReview: %+v", ar) + switch ar.Request.Kind.Kind { + case "PersistentVolumeClaim": + admissionResponse = mutatePVC(ctx, ar.Request) + default: + log.Infof("Skipping mutation for resource type: %q", ar.Request.Kind.Kind) + admissionResponse = &admissionv1.AdmissionResponse{ + Allowed: true, + } + } + log.Debugf("admissionResponse: %+v", admissionResponse) + } + } + admissionReview := admissionv1.AdmissionReview{} + admissionReview.APIVersion = "admission.k8s.io/v1" + admissionReview.Kind = "AdmissionReview" + if admissionResponse != nil { + admissionReview.Response = admissionResponse + if ar.Request != nil { + admissionReview.Response.UID = ar.Request.UID + } + } + resp, err := json.Marshal(admissionReview) + if err != nil { + log.Errorf("Can't encode response: %v", err) + http.Error(w, fmt.Sprintf("could not encode response: %v", err), http.StatusInternalServerError) + } + log.Debugf("Ready to write mutation response") + if _, err := w.Write(resp); err != nil { + log.Errorf("Can't write response: %v", err) + http.Error(w, fmt.Sprintf("could not write response: %v", err), http.StatusInternalServerError) + } +} + +// validateVolumeSnapshot validates VolumeSnapshot deletion requests for vanilla flavor. +// This is an empty implementation that allows all requests by default. +func validateVolumeSnapshot(ctx context.Context, req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse { + log := logger.GetLogger(ctx) + log.Debugf("VolumeSnapshot validation called for operation: %v", req.Operation) + + // Empty implementation - allow all VolumeSnapshot operations by default + return &admissionv1.AdmissionResponse{ + Allowed: true, + } +} + +// mutateNewPVC mutates PersistentVolumeClaim creation requests for vanilla flavor. +// This follows the exact same pattern as cnscsi_admissionhandler.go mutateNewPVC. +func mutateNewPVC(ctx context.Context, req admission.Request) admission.Response { + newPVC := &corev1.PersistentVolumeClaim{} + if err := json.Unmarshal(req.Object.Raw, newPVC); err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + var wasMutated bool + + if featureIsLinkedCloneSupportEnabled && + metav1.HasAnnotation(newPVC.ObjectMeta, common.AnnKeyLinkedClone) && + newPVC.Annotations[common.AnnKeyLinkedClone] == "true" { + // Set the same label + if newPVC.Labels == nil { + newPVC.Labels = make(map[string]string) + } + if _, ok := newPVC.Labels[common.AnnKeyLinkedClone]; !ok { + newPVC.Labels[common.LinkedClonePVCLabel] = newPVC.Annotations[common.AnnKeyLinkedClone] + wasMutated = true + } + } + + if !wasMutated { + return admission.Allowed("") + } + + newRawPVC, err := json.Marshal(newPVC) + if err != nil { + return admission.Errored(http.StatusInternalServerError, err) + } + + return admission.PatchResponseFromRaw(req.Object.Raw, newRawPVC) +} + +// mutatePVC is a wrapper that converts between vanilla HTTP handler types and controller-runtime types +func mutatePVC(ctx context.Context, req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse { + // Convert admissionv1.AdmissionRequest to admission.Request + admissionReq := admission.Request{AdmissionRequest: *req} + + // Call the controller-runtime style function + resp := mutateNewPVC(ctx, admissionReq) + + // Convert patches back to raw JSON for admissionv1.AdmissionResponse + admissionResp := resp.AdmissionResponse + if len(resp.Patches) > 0 { + patchBytes, err := json.Marshal(resp.Patches) + if err != nil { + return &admissionv1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: fmt.Sprintf("Failed to marshal patches: %v", err), + }, + } + } + admissionResp.Patch = patchBytes + patchType := admissionv1.PatchTypeJSONPatch + admissionResp.PatchType = &patchType + } + + return &admissionResp +} diff --git a/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go b/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go index 1784fa631c..a33203724c 100644 --- a/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go +++ b/pkg/syncer/admissionhandler/cnscsi_admissionhandler.go @@ -3,6 +3,7 @@ package admissionhandler import ( "context" "crypto/tls" + _ "crypto/tls/fipsonly" "crypto/x509" "encoding/json" diff --git a/pkg/syncer/admissionhandler/vanilla_mutate_test.go b/pkg/syncer/admissionhandler/vanilla_mutate_test.go new file mode 100644 index 0000000000..a05f3a2323 --- /dev/null +++ b/pkg/syncer/admissionhandler/vanilla_mutate_test.go @@ -0,0 +1,404 @@ +package admissionhandler + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "sigs.k8s.io/vsphere-csi-driver/v3/pkg/csi/service/common" +) + +func TestMutateNewPVC(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + pvc *corev1.PersistentVolumeClaim + linkedCloneSupportEnabled bool + expectedMutated bool + expectedLabels map[string]string + expectedError bool + }{ + { + name: "LinkedClone FSS disabled - no mutation", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: false, + expectedMutated: false, + expectedLabels: nil, + }, + { + name: "LinkedClone FSS enabled but no annotation - no mutation", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: true, + expectedMutated: false, + expectedLabels: nil, + }, + { + name: "LinkedClone FSS enabled with annotation false - no mutation", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "false", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: true, + expectedMutated: false, + expectedLabels: nil, + }, + { + name: "LinkedClone FSS enabled with annotation true - mutation expected", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: true, + expectedMutated: true, + expectedLabels: map[string]string{ + common.LinkedClonePVCLabel: "true", + }, + }, + { + name: "LinkedClone FSS enabled with existing labels - mutation expected", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + Labels: map[string]string{ + "existing-label": "existing-value", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: true, + expectedMutated: true, + expectedLabels: map[string]string{ + "existing-label": "existing-value", + common.LinkedClonePVCLabel: "true", + }, + }, + { + name: "LinkedClone FSS enabled with label already present - no mutation", + pvc: &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + Labels: map[string]string{ + common.LinkedClonePVCLabel: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + }, + linkedCloneSupportEnabled: true, + expectedMutated: false, + expectedLabels: map[string]string{ + common.LinkedClonePVCLabel: "true", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set the global feature flag + originalFeatureFlag := featureIsLinkedCloneSupportEnabled + featureIsLinkedCloneSupportEnabled = tt.linkedCloneSupportEnabled + defer func() { + featureIsLinkedCloneSupportEnabled = originalFeatureFlag + }() + + // Marshal PVC to raw bytes + pvcBytes, err := json.Marshal(tt.pvc) + require.NoError(t, err) + + // Create admission request + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "PersistentVolumeClaim", + }, + Object: runtime.RawExtension{ + Raw: pvcBytes, + }, + }, + } + + // Call the function + resp := mutateNewPVC(ctx, req) + + // Verify response + if tt.expectedError { + assert.False(t, resp.Allowed) + assert.NotNil(t, resp.Result) + return + } + + assert.True(t, resp.Allowed) + // admission.Allowed("") always creates a Result with code 200 + if resp.Result != nil { + assert.Equal(t, int32(200), resp.Result.Code) + } + + if !tt.expectedMutated { + // No mutation expected - should have no patches + assert.Empty(t, resp.Patches) + } else { + // Mutation expected - should have patches + assert.NotEmpty(t, resp.Patches) + + // Debug: print all patches (can be removed in production) + t.Logf("Number of patches: %d", len(resp.Patches)) + for i, patch := range resp.Patches { + t.Logf("Patch %d: Op=%s, Path=%s, Value=%v", i, patch.Operation, patch.Path, patch.Value) + } + + // Verify the patches contain the expected label + found := false + for _, patch := range resp.Patches { + if patch.Operation == "add" { + if patch.Path == "/metadata/labels/"+common.LinkedClonePVCLabel { + // Individual label patch + assert.Equal(t, "true", patch.Value) + found = true + break + } else if patch.Path == "/metadata/labels" { + // Entire labels object patch + if labelsMap, ok := patch.Value.(map[string]interface{}); ok { + if val, exists := labelsMap[common.LinkedClonePVCLabel]; exists { + assert.Equal(t, "true", val) + found = true + break + } + } + } + } + } + assert.True(t, found, "Expected linked clone label patch not found") + } + }) + } +} + +func TestMutateNewPVC_InvalidJSON(t *testing.T) { + ctx := context.Background() + + // Set the global feature flag + originalFeatureFlag := featureIsLinkedCloneSupportEnabled + featureIsLinkedCloneSupportEnabled = true + defer func() { + featureIsLinkedCloneSupportEnabled = originalFeatureFlag + }() + + // Create admission request with invalid JSON + req := admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "PersistentVolumeClaim", + }, + Object: runtime.RawExtension{ + Raw: []byte("invalid json"), + }, + }, + } + + // Call the function + resp := mutateNewPVC(ctx, req) + + // Verify error response + assert.False(t, resp.Allowed) + assert.NotNil(t, resp.Result) + assert.Equal(t, http.StatusInternalServerError, int(resp.Result.Code)) +} + +func TestMutatePVC_Wrapper(t *testing.T) { + ctx := context.Background() + + // Set the global feature flag + originalFeatureFlag := featureIsLinkedCloneSupportEnabled + featureIsLinkedCloneSupportEnabled = true + defer func() { + featureIsLinkedCloneSupportEnabled = originalFeatureFlag + }() + + // Create test PVC with linked clone annotation + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + } + + // Marshal PVC to raw bytes + pvcBytes, err := json.Marshal(pvc) + require.NoError(t, err) + + // Create admission request + req := &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "PersistentVolumeClaim", + }, + Object: runtime.RawExtension{ + Raw: pvcBytes, + }, + } + + // Call the wrapper function + resp := mutatePVC(ctx, req) + + // Verify response + assert.True(t, resp.Allowed) + // admission.Allowed("") always creates a Result with code 200 + if resp.Result != nil { + assert.Equal(t, int32(200), resp.Result.Code) + } + assert.NotNil(t, resp.Patch) + assert.NotNil(t, resp.PatchType) + assert.Equal(t, admissionv1.PatchTypeJSONPatch, *resp.PatchType) + + // Verify the patch contains the expected operations + var patches []map[string]interface{} + err = json.Unmarshal(resp.Patch, &patches) + require.NoError(t, err) + assert.NotEmpty(t, patches) + + // Find the linked clone label patch + found := false + for _, patch := range patches { + if patch["op"] == "add" { + if patch["path"] == "/metadata/labels/"+common.LinkedClonePVCLabel { + // Individual label patch + assert.Equal(t, "true", patch["value"]) + found = true + break + } else if patch["path"] == "/metadata/labels" { + // Entire labels object patch + if labelsMap, ok := patch["value"].(map[string]interface{}); ok { + if val, exists := labelsMap[common.LinkedClonePVCLabel]; exists { + assert.Equal(t, "true", val) + found = true + break + } + } + } + } + } + assert.True(t, found, "Expected linked clone label patch not found") +} + +func TestMutatePVC_Wrapper_NoMutation(t *testing.T) { + ctx := context.Background() + + // Set the global feature flag to disabled + originalFeatureFlag := featureIsLinkedCloneSupportEnabled + featureIsLinkedCloneSupportEnabled = false + defer func() { + featureIsLinkedCloneSupportEnabled = originalFeatureFlag + }() + + // Create test PVC with linked clone annotation + pvc := &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pvc", + Namespace: "test-ns", + Annotations: map[string]string{ + common.AnnKeyLinkedClone: "true", + }, + }, + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + }, + } + + // Marshal PVC to raw bytes + pvcBytes, err := json.Marshal(pvc) + require.NoError(t, err) + + // Create admission request + req := &admissionv1.AdmissionRequest{ + Operation: admissionv1.Create, + Kind: metav1.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "PersistentVolumeClaim", + }, + Object: runtime.RawExtension{ + Raw: pvcBytes, + }, + } + + // Call the wrapper function + resp := mutatePVC(ctx, req) + + // Verify response - should be allowed with no patches + assert.True(t, resp.Allowed) + // admission.Allowed("") always creates a Result with code 200 + if resp.Result != nil { + assert.Equal(t, int32(200), resp.Result.Code) + } + assert.Nil(t, resp.Patch) + assert.Nil(t, resp.PatchType) +}