diff --git a/pkg/validation-webhook/scheme.go b/pkg/validation-webhook/scheme.go index 6ca79c09..538432a0 100644 --- a/pkg/validation-webhook/scheme.go +++ b/pkg/validation-webhook/scheme.go @@ -17,6 +17,7 @@ limitations under the License. package webhook import ( + snapshot "github.com/kubernetes-csi/external-snapshotter/client/v6/clientset/versioned/scheme" admissionv1 "k8s.io/api/admission/v1" admissionv1beta1 "k8s.io/api/admission/v1beta1" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" @@ -40,4 +41,5 @@ func addToScheme(scheme *runtime.Scheme) { utilruntime.Must(admissionregistrationv1beta1.AddToScheme(scheme)) utilruntime.Must(admissionv1.AddToScheme(scheme)) utilruntime.Must(admissionregistrationv1.AddToScheme(scheme)) + utilruntime.Must(snapshot.AddToScheme(scheme)) } diff --git a/pkg/validation-webhook/snapshot.go b/pkg/validation-webhook/snapshot.go index ccb1e0a8..cd4eaa18 100644 --- a/pkg/validation-webhook/snapshot.go +++ b/pkg/validation-webhook/snapshot.go @@ -22,8 +22,11 @@ import ( volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" volumesnapshotv1beta1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1beta1" + storagelisters "github.com/kubernetes-csi/external-snapshotter/client/v6/listers/volumesnapshot/v1" + "github.com/kubernetes-csi/external-snapshotter/v6/pkg/utils" v1 "k8s.io/api/admission/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/klog/v2" ) @@ -36,10 +39,26 @@ var ( SnapshotContentV1Beta1GVR = metav1.GroupVersionResource{Group: volumesnapshotv1beta1.GroupName, Version: "v1beta1", Resource: "volumesnapshotcontents"} // SnapshotContentV1GVR is GroupVersionResource for v1 VolumeSnapshotContents SnapshotContentV1GVR = metav1.GroupVersionResource{Group: volumesnapshotv1.GroupName, Version: "v1", Resource: "volumesnapshotcontents"} + // SnapshotContentV1GVR is GroupVersionResource for v1 VolumeSnapshotContents + SnapshotClassV1GVR = metav1.GroupVersionResource{Group: volumesnapshotv1.GroupName, Version: "v1", Resource: "volumesnapshotclasses"} ) +type SnapshotAdmitter interface { + Admit(v1.AdmissionReview) *v1.AdmissionResponse +} + +type admitter struct { + lister storagelisters.VolumeSnapshotClassLister +} + +func NewSnapshotAdmitter(lister storagelisters.VolumeSnapshotClassLister) SnapshotAdmitter { + return &admitter{ + lister: lister, + } +} + // Add a label {"added-label": "yes"} to the object -func admitSnapshot(ar v1.AdmissionReview) *v1.AdmissionResponse { +func (a admitter) Admit(ar v1.AdmissionReview) *v1.AdmissionResponse { klog.V(2).Info("admitting volumesnapshots or volumesnapshotcontents") reviewResponse := &v1.AdmissionResponse{ @@ -106,6 +125,18 @@ func admitSnapshot(ar v1.AdmissionReview) *v1.AdmissionResponse { return toV1AdmissionResponse(err) } return decideSnapshotContentV1(snapcontent, oldSnapcontent, isUpdate) + case SnapshotClassV1GVR: + snapClass := &volumesnapshotv1.VolumeSnapshotClass{} + if _, _, err := deserializer.Decode(raw, nil, snapClass); err != nil { + klog.Error(err) + return toV1AdmissionResponse(err) + } + oldSnapClass := &volumesnapshotv1.VolumeSnapshotClass{} + if _, _, err := deserializer.Decode(oldRaw, nil, oldSnapClass); err != nil { + klog.Error(err) + return toV1AdmissionResponse(err) + } + return decideSnapshotClassV1(snapClass, oldSnapClass, a.lister) default: err := fmt.Errorf("expect resource to be %s or %s", SnapshotV1Beta1GVR, SnapshotContentV1Beta1GVR) klog.Error(err) @@ -223,6 +254,43 @@ func decideSnapshotContentV1(snapcontent, oldSnapcontent *volumesnapshotv1.Volum return reviewResponse } +func decideSnapshotClassV1(snapClass, oldSnapClass *volumesnapshotv1.VolumeSnapshotClass, lister storagelisters.VolumeSnapshotClassLister) *v1.AdmissionResponse { + reviewResponse := &v1.AdmissionResponse{ + Allowed: true, + Result: &metav1.Status{}, + } + + // Only Validate when a new snapClass is being set as a default. + if snapClass.Annotations[utils.IsDefaultSnapshotClassAnnotation] != "true" { + return reviewResponse + } + + // If Old snapshot class has this, then we can assume that it was validated if driver is the same. + if oldSnapClass.Annotations[utils.IsDefaultSnapshotClassAnnotation] == "true" && oldSnapClass.Driver == snapClass.Driver { + return reviewResponse + } + + ret, err := lister.List(labels.Everything()) + if err != nil { + reviewResponse.Allowed = false + reviewResponse.Result.Message = err.Error() + return reviewResponse + } + + for _, snapshotClass := range ret { + if snapshotClass.Annotations[utils.IsDefaultSnapshotClassAnnotation] != "true" { + continue + } + if snapshotClass.Driver == snapClass.Driver { + reviewResponse.Allowed = false + reviewResponse.Result.Message = fmt.Sprintf("default snapshot class: %v already exits for driver: %v", snapshotClass.Name, snapClass.Driver) + return reviewResponse + } + } + + return reviewResponse +} + func strPtrDereference(s *string) string { if s == nil { return "" diff --git a/pkg/validation-webhook/snapshot_test.go b/pkg/validation-webhook/snapshot_test.go index 078d3d8e..796a067f 100644 --- a/pkg/validation-webhook/snapshot_test.go +++ b/pkg/validation-webhook/snapshot_test.go @@ -23,8 +23,12 @@ import ( volumesnapshotv1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1" volumesnapshotv1beta1 "github.com/kubernetes-csi/external-snapshotter/client/v6/apis/volumesnapshot/v1beta1" + storagelisters "github.com/kubernetes-csi/external-snapshotter/client/v6/listers/volumesnapshot/v1" + "github.com/kubernetes-csi/external-snapshotter/v6/pkg/utils" v1 "k8s.io/api/admission/v1" core_v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" ) @@ -208,7 +212,8 @@ func TestAdmitVolumeSnapshotV1beta1(t *testing.T) { Operation: tc.operation, }, } - response := admitSnapshot(review) + sa := NewSnapshotAdmitter(nil) + response := sa.Admit(review) shouldAdmit := response.Allowed msg := response.Result.Message @@ -391,7 +396,8 @@ func TestAdmitVolumeSnapshotV1(t *testing.T) { Operation: tc.operation, }, } - response := admitSnapshot(review) + sa := NewSnapshotAdmitter(nil) + response := sa.Admit(review) shouldAdmit := response.Allowed msg := response.Result.Message @@ -541,7 +547,8 @@ func TestAdmitVolumeSnapshotContentV1beta1(t *testing.T) { Operation: tc.operation, }, } - response := admitSnapshot(review) + sa := NewSnapshotAdmitter(nil) + response := sa.Admit(review) shouldAdmit := response.Allowed msg := response.Result.Message @@ -685,7 +692,293 @@ func TestAdmitVolumeSnapshotContentV1(t *testing.T) { Operation: tc.operation, }, } - response := admitSnapshot(review) + sa := NewSnapshotAdmitter(nil) + response := sa.Admit(review) + shouldAdmit := response.Allowed + msg := response.Result.Message + + expectedResponse := tc.shouldAdmit + expectedMsg := tc.msg + + if shouldAdmit != expectedResponse { + t.Errorf("expected \"%v\" to equal \"%v\"", shouldAdmit, expectedResponse) + } + if msg != expectedMsg { + t.Errorf("expected \"%v\" to equal \"%v\"", msg, expectedMsg) + } + }) + } +} + +type fakeSnapshotLister struct { + values []*volumesnapshotv1.VolumeSnapshotClass +} + +func (f *fakeSnapshotLister) List(selector labels.Selector) (ret []*volumesnapshotv1.VolumeSnapshotClass, err error) { + return f.values, nil +} + +func (f *fakeSnapshotLister) Get(name string) (*volumesnapshotv1.VolumeSnapshotClass, error) { + for _, v := range f.values { + if v.Name == name { + return v, nil + } + } + return nil, nil +} + +func TestAdmitVolumeSnapshotClassV1(t *testing.T) { + testCases := []struct { + name string + volumeSnapshotClass *volumesnapshotv1.VolumeSnapshotClass + oldVolumeSnapshotClass *volumesnapshotv1.VolumeSnapshotClass + shouldAdmit bool + msg string + operation v1.Operation + lister storagelisters.VolumeSnapshotClassLister + }{ + { + name: "new default for class with no existing classes", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{}, + shouldAdmit: true, + msg: "", + operation: v1.Create, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{}}, + }, + { + name: "new default for class for with existing default class different drivers", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{}, + shouldAdmit: true, + msg: "", + operation: v1.Create, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "existing.test.csi.io", + }, + }}, + }, + { + name: "new default for class with existing default class same driver", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{}, + shouldAdmit: false, + msg: "default snapshot class: driver-a already exits for driver: test.csi.io", + operation: v1.Create, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "driver-a", + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + }}, + }, + { + name: "default for class with existing default class same driver update", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + shouldAdmit: true, + msg: "", + operation: v1.Update, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + }}, + }, + { + name: "new snapshot for class with existing default class same driver", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{}, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{}, + shouldAdmit: true, + msg: "", + operation: v1.Create, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + }}, + }, + { + name: "new snapshot for class with existing default classes", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{}, + shouldAdmit: false, + msg: "default snapshot class: driver-is-default already exits for driver: test.csi.io", + operation: v1.Create, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "driver-is-default", + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + }}, + }, + { + name: "update snapshot class to new driver with existing default classes", + volumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "driver.test.csi.io", + }, + oldVolumeSnapshotClass: &volumesnapshotv1.VolumeSnapshotClass{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + shouldAdmit: false, + msg: "default snapshot class: driver-test-default already exits for driver: driver.test.csi.io", + operation: v1.Update, + lister: &fakeSnapshotLister{values: []*volumesnapshotv1.VolumeSnapshotClass{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "driver-is-default", + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "test.csi.io", + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "driver-test-default", + Annotations: map[string]string{ + utils.IsDefaultSnapshotClassAnnotation: "true", + }, + }, + Driver: "driver.test.csi.io", + }, + }}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + snapshotContent := tc.volumeSnapshotClass + raw, err := json.Marshal(snapshotContent) + if err != nil { + t.Fatal(err) + } + oldSnapshotClass := tc.oldVolumeSnapshotClass + oldRaw, err := json.Marshal(oldSnapshotClass) + if err != nil { + t.Fatal(err) + } + review := v1.AdmissionReview{ + Request: &v1.AdmissionRequest{ + Object: runtime.RawExtension{ + Raw: raw, + }, + OldObject: runtime.RawExtension{ + Raw: oldRaw, + }, + Resource: SnapshotClassV1GVR, + Operation: tc.operation, + }, + } + sa := NewSnapshotAdmitter(tc.lister) + response := sa.Admit(review) + shouldAdmit := response.Allowed msg := response.Result.Message diff --git a/pkg/validation-webhook/webhook.go b/pkg/validation-webhook/webhook.go index 6a568f8a..1e00f692 100644 --- a/pkg/validation-webhook/webhook.go +++ b/pkg/validation-webhook/webhook.go @@ -23,19 +23,26 @@ import ( "fmt" "io/ioutil" "net/http" + "os" + clientset "github.com/kubernetes-csi/external-snapshotter/client/v6/clientset/versioned" + storagelisters "github.com/kubernetes-csi/external-snapshotter/client/v6/listers/volumesnapshot/v1" "github.com/spf13/cobra" + informers "github.com/kubernetes-csi/external-snapshotter/client/v6/informers/externalversions" v1 "k8s.io/api/admission/v1" "k8s.io/api/admission/v1beta1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" "k8s.io/klog/v2" ) var ( - certFile string - keyFile string - port int + certFile string + keyFile string + kubeconfigFile string + port int ) // CmdWebhook is used by Cobra. @@ -58,6 +65,8 @@ func init() { "Secure port that the webhook listens on") CmdWebhook.MarkFlagRequired("tls-cert-file") CmdWebhook.MarkFlagRequired("tls-private-key-file") + // Add optional flag for kubeconfig + CmdWebhook.Flags().StringVar(&kubeconfigFile, "kubeconfig", "", "kubeconfig file to use for volumesnapshotclasses") } // admitv1beta1Func handles a v1beta1 admission @@ -68,14 +77,12 @@ type admitv1Func func(v1.AdmissionReview) *v1.AdmissionResponse // admitHandler is a handler, for both validators and mutators, that supports multiple admission review versions type admitHandler struct { - v1beta1 admitv1beta1Func - v1 admitv1Func + SnapshotAdmitter } -func newDelegateToV1AdmitHandler(f admitv1Func) admitHandler { +func newDelegateToV1AdmitHandler(sa SnapshotAdmitter) admitHandler { return admitHandler{ - v1beta1: delegateV1beta1AdmitToV1(f), - v1: f, + SnapshotAdmitter: sa, } } @@ -137,7 +144,7 @@ func serve(w http.ResponseWriter, r *http.Request, admit admitHandler) { } responseAdmissionReview := &v1beta1.AdmissionReview{} responseAdmissionReview.SetGroupVersionKind(*gvk) - responseAdmissionReview.Response = admit.v1beta1(*requestedAdmissionReview) + responseAdmissionReview.Response = delegateV1beta1AdmitToV1(admit.Admit)(*requestedAdmissionReview) responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID responseObj = responseAdmissionReview case v1.SchemeGroupVersion.WithKind("AdmissionReview"): @@ -150,7 +157,7 @@ func serve(w http.ResponseWriter, r *http.Request, admit admitHandler) { } responseAdmissionReview := &v1.AdmissionReview{} responseAdmissionReview.SetGroupVersionKind(*gvk) - responseAdmissionReview.Response = admit.v1(*requestedAdmissionReview) + responseAdmissionReview.Response = admit.Admit(*requestedAdmissionReview) responseAdmissionReview.Response.UID = requestedAdmissionReview.Request.UID responseObj = responseAdmissionReview default: @@ -173,21 +180,29 @@ func serve(w http.ResponseWriter, r *http.Request, admit admitHandler) { } } -func serveSnapshotRequest(w http.ResponseWriter, r *http.Request) { - serve(w, r, newDelegateToV1AdmitHandler(admitSnapshot)) +type serveWebhook struct { + lister storagelisters.VolumeSnapshotClassLister } -func startServer(ctx context.Context, tlsConfig *tls.Config, cw *CertWatcher) error { +func (s serveWebhook) ServeHTTP(w http.ResponseWriter, r *http.Request) { + serve(w, r, newDelegateToV1AdmitHandler(NewSnapshotAdmitter(s.lister))) +} + +func startServer(ctx context.Context, tlsConfig *tls.Config, cw *CertWatcher, lister storagelisters.VolumeSnapshotClassLister) error { go func() { klog.Info("Starting certificate watcher") if err := cw.Start(ctx); err != nil { klog.Errorf("certificate watcher error: %v", err) } }() + // Pipe through the informer at some point here. + s := &serveWebhook{ + lister: lister, + } fmt.Println("Starting webhook server") mux := http.NewServeMux() - mux.HandleFunc("/volumesnapshot", serveSnapshotRequest) + mux.Handle("/volumesnapshot", s) mux.HandleFunc("/readyz", func(w http.ResponseWriter, req *http.Request) { w.Write([]byte("ok")) }) srv := &http.Server{ Handler: mux, @@ -215,7 +230,35 @@ func main(cmd *cobra.Command, args []string) { GetCertificate: cw.GetCertificate, } - if err := startServer(ctx, tlsConfig, cw); err != nil { + // Create an indexer. + // Create the client config. Use kubeconfig if given, otherwise assume in-cluster. + config, err := buildConfig(kubeconfigFile) + if err != nil { + klog.Error(err.Error()) + os.Exit(1) + } + snapClient, err := clientset.NewForConfig(config) + if err != nil { + klog.Errorf("Error building snapshot clientset: %s", err.Error()) + os.Exit(1) + } + + factory := informers.NewSharedInformerFactory(snapClient, 0) + lister := factory.Snapshot().V1().VolumeSnapshotClasses().Lister() + + //Start the informers + factory.Start(ctx.Done()) + //wait for the caches to sync + factory.WaitForCacheSync(ctx.Done()) + + if err := startServer(ctx, tlsConfig, cw, lister); err != nil { klog.Fatalf("server stopped: %v", err) } } + +func buildConfig(kubeconfig string) (*rest.Config, error) { + if kubeconfig != "" { + return clientcmd.BuildConfigFromFlags("", kubeconfig) + } + return rest.InClusterConfig() +} diff --git a/pkg/validation-webhook/webhook_test.go b/pkg/validation-webhook/webhook_test.go index 1c325a5b..f1e81be5 100644 --- a/pkg/validation-webhook/webhook_test.go +++ b/pkg/validation-webhook/webhook_test.go @@ -45,7 +45,7 @@ func TestWebhookCertReload(t *testing.T) { GetCertificate: cw.GetCertificate, } go func() { - if err := startServer(ctx, tlsConfig, cw); err != nil { + if err := startServer(ctx, tlsConfig, cw, &fakeSnapshotLister{}); err != nil { panic(err) } }()