From 378bb80fc89598ed28416044b9d8731691c460ff Mon Sep 17 00:00:00 2001 From: "Dr. Stefan Schimanski" Date: Tue, 16 Jan 2018 10:37:41 +0100 Subject: [PATCH] admission/webhook: refactor to webhook = generic-webhook + source + dispatcher - unify test cases - remove broken VersionedAttributes override abstraction This overriding had no effect. The versioned.Attributes were never used as admission.Attributes.Better make the versioned objects explicit than hiding them under a wrong abstraction. - remove wrapping of scheme.Convert - internalize conversion package Kubernetes-commit: 72f8a369d021037ca6179339d50ad595b5462a6c --- .../configuration/mutating_webhook_manager.go | 26 +- .../mutating_webhook_manager_test.go | 104 +-- .../validating_webhook_manager.go | 27 +- .../validating_webhook_manager_test.go | 104 +-- pkg/admission/plugin/webhook/config/client.go | 8 +- .../{versioned => generic}/conversion.go | 24 +- .../{versioned => generic}/conversion_test.go | 92 +-- .../plugin/webhook/generic/interfaces.go | 45 ++ .../plugin/webhook/generic/webhook.go | 202 ++++++ .../plugin/webhook/mutating/admission.go | 311 -------- .../plugin/webhook/mutating/admission_test.go | 653 ----------------- .../plugin/webhook/mutating/dispatcher.go | 118 +++ .../webhook/mutating/dispatcher_test.go | 136 ++++ .../plugin/webhook/mutating/plugin.go | 96 +++ .../plugin/webhook/mutating/plugin_test.go | 143 ++++ .../plugin/webhook/request/admissionreview.go | 8 +- .../testing/authentication_info_resolver.go | 63 ++ .../webhook/testing/service_resolver.go | 42 ++ .../plugin/webhook/testing/testcase.go | 426 +++++++++++ .../plugin/webhook/testing/webhook_server.go | 94 +++ .../plugin/webhook/validating/admission.go | 305 -------- .../webhook/validating/admission_test.go | 678 ------------------ .../plugin/webhook/validating/dispatcher.go | 118 +++ .../plugin/webhook/validating/plugin.go | 64 ++ .../plugin/webhook/validating/plugin_test.go | 149 ++++ .../plugin/webhook/versioned/attributes.go | 42 -- pkg/admission/plugin/webhook/versioned/doc.go | 19 - pkg/admission/plugins.go | 6 +- 28 files changed, 1807 insertions(+), 2296 deletions(-) rename pkg/admission/plugin/webhook/{versioned => generic}/conversion.go (61%) rename pkg/admission/plugin/webhook/{versioned => generic}/conversion_test.go (63%) create mode 100644 pkg/admission/plugin/webhook/generic/interfaces.go create mode 100644 pkg/admission/plugin/webhook/generic/webhook.go delete mode 100644 pkg/admission/plugin/webhook/mutating/admission.go delete mode 100644 pkg/admission/plugin/webhook/mutating/admission_test.go create mode 100644 pkg/admission/plugin/webhook/mutating/dispatcher.go create mode 100644 pkg/admission/plugin/webhook/mutating/dispatcher_test.go create mode 100644 pkg/admission/plugin/webhook/mutating/plugin.go create mode 100644 pkg/admission/plugin/webhook/mutating/plugin_test.go create mode 100644 pkg/admission/plugin/webhook/testing/authentication_info_resolver.go create mode 100644 pkg/admission/plugin/webhook/testing/service_resolver.go create mode 100644 pkg/admission/plugin/webhook/testing/testcase.go create mode 100644 pkg/admission/plugin/webhook/testing/webhook_server.go delete mode 100644 pkg/admission/plugin/webhook/validating/admission.go delete mode 100644 pkg/admission/plugin/webhook/validating/admission_test.go create mode 100644 pkg/admission/plugin/webhook/validating/dispatcher.go create mode 100644 pkg/admission/plugin/webhook/validating/plugin.go create mode 100644 pkg/admission/plugin/webhook/validating/plugin_test.go delete mode 100644 pkg/admission/plugin/webhook/versioned/attributes.go delete mode 100644 pkg/admission/plugin/webhook/versioned/doc.go diff --git a/pkg/admission/configuration/mutating_webhook_manager.go b/pkg/admission/configuration/mutating_webhook_manager.go index 3c0990699..4b2256e11 100644 --- a/pkg/admission/configuration/mutating_webhook_manager.go +++ b/pkg/admission/configuration/mutating_webhook_manager.go @@ -24,21 +24,27 @@ import ( "k8s.io/api/admissionregistration/v1beta1" "k8s.io/apimachinery/pkg/labels" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - admissionregistrationinformers "k8s.io/client-go/informers/admissionregistration/v1beta1" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/client-go/informers" admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1beta1" "k8s.io/client-go/tools/cache" ) -// MutatingWebhookConfigurationManager collects the mutating webhook objects so that they can be called. -type MutatingWebhookConfigurationManager struct { +// mutatingWebhookConfigurationManager collects the mutating webhook objects so that they can be called. +type mutatingWebhookConfigurationManager struct { configuration *atomic.Value lister admissionregistrationlisters.MutatingWebhookConfigurationLister + hasSynced func() bool } -func NewMutatingWebhookConfigurationManager(informer admissionregistrationinformers.MutatingWebhookConfigurationInformer) *MutatingWebhookConfigurationManager { - manager := &MutatingWebhookConfigurationManager{ +var _ generic.Source = &mutatingWebhookConfigurationManager{} + +func NewMutatingWebhookConfigurationManager(f informers.SharedInformerFactory) generic.Source { + informer := f.Admissionregistration().V1beta1().MutatingWebhookConfigurations() + manager := &mutatingWebhookConfigurationManager{ configuration: &atomic.Value{}, lister: informer.Lister(), + hasSynced: informer.Informer().HasSynced, } // Start with an empty list @@ -55,11 +61,15 @@ func NewMutatingWebhookConfigurationManager(informer admissionregistrationinform } // Webhooks returns the merged MutatingWebhookConfiguration. -func (m *MutatingWebhookConfigurationManager) Webhooks() *v1beta1.MutatingWebhookConfiguration { - return m.configuration.Load().(*v1beta1.MutatingWebhookConfiguration) +func (m *mutatingWebhookConfigurationManager) Webhooks() []v1beta1.Webhook { + return m.configuration.Load().(*v1beta1.MutatingWebhookConfiguration).Webhooks } -func (m *MutatingWebhookConfigurationManager) updateConfiguration() { +func (m *mutatingWebhookConfigurationManager) HasSynced() bool { + return m.hasSynced() +} + +func (m *mutatingWebhookConfigurationManager) updateConfiguration() { configurations, err := m.lister.List(labels.Everything()) if err != nil { utilruntime.HandleError(fmt.Errorf("error updating configuration: %v", err)) diff --git a/pkg/admission/configuration/mutating_webhook_manager_test.go b/pkg/admission/configuration/mutating_webhook_manager_test.go index d6f4f1a45..9bc037f5a 100644 --- a/pkg/admission/configuration/mutating_webhook_manager_test.go +++ b/pkg/admission/configuration/mutating_webhook_manager_test.go @@ -17,107 +17,47 @@ limitations under the License. package configuration import ( - "fmt" "reflect" "testing" - "time" "k8s.io/api/admissionregistration/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1beta1" - "k8s.io/client-go/tools/cache" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" ) -type fakeMutatingWebhookConfigSharedInformer struct { - informer *fakeMutatingWebhookConfigInformer - lister *fakeMutatingWebhookConfigLister -} - -func (f *fakeMutatingWebhookConfigSharedInformer) Informer() cache.SharedIndexInformer { - return f.informer -} -func (f *fakeMutatingWebhookConfigSharedInformer) Lister() admissionregistrationlisters.MutatingWebhookConfigurationLister { - return f.lister -} - -type fakeMutatingWebhookConfigInformer struct { - eventHandler cache.ResourceEventHandler -} - -func (f *fakeMutatingWebhookConfigInformer) AddEventHandler(handler cache.ResourceEventHandler) { - fmt.Println("added handler") - f.eventHandler = handler -} -func (f *fakeMutatingWebhookConfigInformer) AddEventHandlerWithResyncPeriod(handler cache.ResourceEventHandler, resyncPeriod time.Duration) { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) GetStore() cache.Store { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) GetController() cache.Controller { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) Run(stopCh <-chan struct{}) { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) HasSynced() bool { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) LastSyncResourceVersion() string { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) AddIndexers(indexers cache.Indexers) error { - panic("unsupported") -} -func (f *fakeMutatingWebhookConfigInformer) GetIndexer() cache.Indexer { panic("unsupported") } - -type fakeMutatingWebhookConfigLister struct { - list []*v1beta1.MutatingWebhookConfiguration - err error -} - -func (f *fakeMutatingWebhookConfigLister) List(selector labels.Selector) (ret []*v1beta1.MutatingWebhookConfiguration, err error) { - return f.list, f.err -} - -func (f *fakeMutatingWebhookConfigLister) Get(name string) (*v1beta1.MutatingWebhookConfiguration, error) { - panic("unsupported") -} - func TestGetMutatingWebhookConfig(t *testing.T) { - informer := &fakeMutatingWebhookConfigSharedInformer{ - informer: &fakeMutatingWebhookConfigInformer{}, - lister: &fakeMutatingWebhookConfigLister{}, - } + // Build a test client that the admission plugin can use to look up the MutatingWebhookConfiguration + client := fake.NewSimpleClientset() + informerFactory := informers.NewSharedInformerFactory(client, 0) + stop := make(chan struct{}) + defer close(stop) + informerFactory.Start(stop) + informerFactory.WaitForCacheSync(stop) + + configManager := NewMutatingWebhookConfigurationManager(informerFactory).(*mutatingWebhookConfigurationManager) + configManager.updateConfiguration() // no configurations - informer.lister.list = nil - manager := NewMutatingWebhookConfigurationManager(informer) - if configurations := manager.Webhooks(); len(configurations.Webhooks) != 0 { - t.Errorf("expected empty webhooks, but got %v", configurations.Webhooks) + if configurations := configManager.Webhooks(); len(configurations) != 0 { + t.Errorf("expected empty webhooks, but got %v", configurations) } - // list err webhookConfiguration := &v1beta1.MutatingWebhookConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "webhook1"}, Webhooks: []v1beta1.Webhook{{Name: "webhook1.1"}}, } - informer.lister.list = []*v1beta1.MutatingWebhookConfiguration{webhookConfiguration.DeepCopy()} - informer.lister.err = fmt.Errorf("mutating webhook configuration list error") - informer.informer.eventHandler.OnAdd(webhookConfiguration.DeepCopy()) - if configurations := manager.Webhooks(); len(configurations.Webhooks) != 0 { - t.Errorf("expected empty webhooks, but got %v", configurations.Webhooks) - } + + mutatingInformer := informerFactory.Admissionregistration().V1beta1().MutatingWebhookConfigurations() + mutatingInformer.Informer().GetIndexer().Add(webhookConfiguration) + configManager.updateConfiguration() // configuration populated - informer.lister.err = nil - informer.informer.eventHandler.OnAdd(webhookConfiguration.DeepCopy()) - configurations := manager.Webhooks() - if len(configurations.Webhooks) == 0 { + configurations := configManager.Webhooks() + if len(configurations) == 0 { t.Errorf("expected non empty webhooks") } - if !reflect.DeepEqual(configurations.Webhooks, webhookConfiguration.Webhooks) { - t.Errorf("Expected\n%#v\ngot\n%#v", webhookConfiguration.Webhooks, configurations.Webhooks) + if !reflect.DeepEqual(configurations, webhookConfiguration.Webhooks) { + t.Errorf("Expected\n%#v\ngot\n%#v", webhookConfiguration.Webhooks, configurations) } } diff --git a/pkg/admission/configuration/validating_webhook_manager.go b/pkg/admission/configuration/validating_webhook_manager.go index 33644f57f..9258258f6 100644 --- a/pkg/admission/configuration/validating_webhook_manager.go +++ b/pkg/admission/configuration/validating_webhook_manager.go @@ -24,21 +24,27 @@ import ( "k8s.io/api/admissionregistration/v1beta1" "k8s.io/apimachinery/pkg/labels" utilruntime "k8s.io/apimachinery/pkg/util/runtime" - admissionregistrationinformers "k8s.io/client-go/informers/admissionregistration/v1beta1" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/client-go/informers" admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1beta1" "k8s.io/client-go/tools/cache" ) -// ValidatingWebhookConfigurationManager collects the validating webhook objects so that they can be called. -type ValidatingWebhookConfigurationManager struct { +// validatingWebhookConfigurationManager collects the validating webhook objects so that they can be called. +type validatingWebhookConfigurationManager struct { configuration *atomic.Value lister admissionregistrationlisters.ValidatingWebhookConfigurationLister + hasSynced func() bool } -func NewValidatingWebhookConfigurationManager(informer admissionregistrationinformers.ValidatingWebhookConfigurationInformer) *ValidatingWebhookConfigurationManager { - manager := &ValidatingWebhookConfigurationManager{ +var _ generic.Source = &validatingWebhookConfigurationManager{} + +func NewValidatingWebhookConfigurationManager(f informers.SharedInformerFactory) generic.Source { + informer := f.Admissionregistration().V1beta1().ValidatingWebhookConfigurations() + manager := &validatingWebhookConfigurationManager{ configuration: &atomic.Value{}, lister: informer.Lister(), + hasSynced: informer.Informer().HasSynced, } // Start with an empty list @@ -55,11 +61,16 @@ func NewValidatingWebhookConfigurationManager(informer admissionregistrationinfo } // Webhooks returns the merged ValidatingWebhookConfiguration. -func (v *ValidatingWebhookConfigurationManager) Webhooks() *v1beta1.ValidatingWebhookConfiguration { - return v.configuration.Load().(*v1beta1.ValidatingWebhookConfiguration) +func (v *validatingWebhookConfigurationManager) Webhooks() []v1beta1.Webhook { + return v.configuration.Load().(*v1beta1.ValidatingWebhookConfiguration).Webhooks } -func (v *ValidatingWebhookConfigurationManager) updateConfiguration() { +// HasSynced returns true if the shared informers have synced. +func (v *validatingWebhookConfigurationManager) HasSynced() bool { + return v.hasSynced() +} + +func (v *validatingWebhookConfigurationManager) updateConfiguration() { configurations, err := v.lister.List(labels.Everything()) if err != nil { utilruntime.HandleError(fmt.Errorf("error updating configuration: %v", err)) diff --git a/pkg/admission/configuration/validating_webhook_manager_test.go b/pkg/admission/configuration/validating_webhook_manager_test.go index 6505b2b9b..153b4df48 100644 --- a/pkg/admission/configuration/validating_webhook_manager_test.go +++ b/pkg/admission/configuration/validating_webhook_manager_test.go @@ -17,107 +17,49 @@ limitations under the License. package configuration import ( - "fmt" "reflect" "testing" - "time" "k8s.io/api/admissionregistration/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - admissionregistrationlisters "k8s.io/client-go/listers/admissionregistration/v1beta1" - "k8s.io/client-go/tools/cache" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes/fake" ) -type fakeValidatingWebhookConfigSharedInformer struct { - informer *fakeValidatingWebhookConfigInformer - lister *fakeValidatingWebhookConfigLister -} +func TestGetValidatingWebhookConfig(t *testing.T) { + // Build a test client that the admission plugin can use to look up the ValidatingWebhookConfiguration + client := fake.NewSimpleClientset() + informerFactory := informers.NewSharedInformerFactory(client, 0) + stop := make(chan struct{}) + defer close(stop) + informerFactory.Start(stop) + informerFactory.WaitForCacheSync(stop) -func (f *fakeValidatingWebhookConfigSharedInformer) Informer() cache.SharedIndexInformer { - return f.informer -} -func (f *fakeValidatingWebhookConfigSharedInformer) Lister() admissionregistrationlisters.ValidatingWebhookConfigurationLister { - return f.lister -} - -type fakeValidatingWebhookConfigInformer struct { - eventHandler cache.ResourceEventHandler -} - -func (f *fakeValidatingWebhookConfigInformer) AddEventHandler(handler cache.ResourceEventHandler) { - fmt.Println("added handler") - f.eventHandler = handler -} -func (f *fakeValidatingWebhookConfigInformer) AddEventHandlerWithResyncPeriod(handler cache.ResourceEventHandler, resyncPeriod time.Duration) { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) GetStore() cache.Store { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) GetController() cache.Controller { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) Run(stopCh <-chan struct{}) { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) HasSynced() bool { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) LastSyncResourceVersion() string { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) AddIndexers(indexers cache.Indexers) error { - panic("unsupported") -} -func (f *fakeValidatingWebhookConfigInformer) GetIndexer() cache.Indexer { panic("unsupported") } - -type fakeValidatingWebhookConfigLister struct { - list []*v1beta1.ValidatingWebhookConfiguration - err error -} - -func (f *fakeValidatingWebhookConfigLister) List(selector labels.Selector) (ret []*v1beta1.ValidatingWebhookConfiguration, err error) { - return f.list, f.err -} - -func (f *fakeValidatingWebhookConfigLister) Get(name string) (*v1beta1.ValidatingWebhookConfiguration, error) { - panic("unsupported") -} - -func TestGettValidatingWebhookConfig(t *testing.T) { - informer := &fakeValidatingWebhookConfigSharedInformer{ - informer: &fakeValidatingWebhookConfigInformer{}, - lister: &fakeValidatingWebhookConfigLister{}, + manager := NewValidatingWebhookConfigurationManager(informerFactory) + if validatingConfig, ok := manager.(*validatingWebhookConfigurationManager); ok { + validatingConfig.updateConfiguration() } - // no configurations - informer.lister.list = nil - manager := NewValidatingWebhookConfigurationManager(informer) - if configurations := manager.Webhooks(); len(configurations.Webhooks) != 0 { - t.Errorf("expected empty webhooks, but got %v", configurations.Webhooks) + if configurations := manager.Webhooks(); len(configurations) != 0 { + t.Errorf("expected empty webhooks, but got %v", configurations) } - // list error webhookConfiguration := &v1beta1.ValidatingWebhookConfiguration{ ObjectMeta: metav1.ObjectMeta{Name: "webhook1"}, Webhooks: []v1beta1.Webhook{{Name: "webhook1.1"}}, } - informer.lister.list = []*v1beta1.ValidatingWebhookConfiguration{webhookConfiguration.DeepCopy()} - informer.lister.err = fmt.Errorf("validating webhook configuration list error") - informer.informer.eventHandler.OnAdd(webhookConfiguration.DeepCopy()) - if configurations := manager.Webhooks(); len(configurations.Webhooks) != 0 { - t.Errorf("expected empty webhooks, but got %v", configurations.Webhooks) - } + validatingInformer := informerFactory.Admissionregistration().V1beta1().ValidatingWebhookConfigurations() + validatingInformer.Informer().GetIndexer().Add(webhookConfiguration) + if validatingConfig, ok := manager.(*validatingWebhookConfigurationManager); ok { + validatingConfig.updateConfiguration() + } // configuration populated - informer.lister.err = nil - informer.informer.eventHandler.OnAdd(webhookConfiguration.DeepCopy()) configurations := manager.Webhooks() - if len(configurations.Webhooks) == 0 { + if len(configurations) == 0 { t.Errorf("expected non empty webhooks") } - if !reflect.DeepEqual(configurations.Webhooks, webhookConfiguration.Webhooks) { - t.Errorf("Expected\n%#v\ngot\n%#v", webhookConfiguration.Webhooks, configurations.Webhooks) + if !reflect.DeepEqual(configurations, webhookConfiguration.Webhooks) { + t.Errorf("Expected\n%#v\ngot\n%#v", webhookConfiguration.Webhooks, configurations) } } diff --git a/pkg/admission/plugin/webhook/config/client.go b/pkg/admission/plugin/webhook/config/client.go index bfc9bbd7f..a6a36855d 100644 --- a/pkg/admission/plugin/webhook/config/client.go +++ b/pkg/admission/plugin/webhook/config/client.go @@ -50,7 +50,7 @@ type ClientManager struct { cache *lru.Cache } -// NewClientManager creates a ClientManager. +// NewClientManager creates a clientManager. func NewClientManager() (ClientManager, error) { cache, err := lru.New(defaultCacheSize) if err != nil { @@ -90,13 +90,13 @@ func (cm *ClientManager) SetServiceResolver(sr ServiceResolver) { func (cm *ClientManager) Validate() error { var errs []error if cm.negotiatedSerializer == nil { - errs = append(errs, fmt.Errorf("the ClientManager requires a negotiatedSerializer")) + errs = append(errs, fmt.Errorf("the clientManager requires a negotiatedSerializer")) } if cm.serviceResolver == nil { - errs = append(errs, fmt.Errorf("the ClientManager requires a serviceResolver")) + errs = append(errs, fmt.Errorf("the clientManager requires a serviceResolver")) } if cm.authInfoResolver == nil { - errs = append(errs, fmt.Errorf("the ClientManager requires an authInfoResolver")) + errs = append(errs, fmt.Errorf("the clientManager requires an authInfoResolver")) } return utilerrors.NewAggregate(errs) } diff --git a/pkg/admission/plugin/webhook/versioned/conversion.go b/pkg/admission/plugin/webhook/generic/conversion.go similarity index 61% rename from pkg/admission/plugin/webhook/versioned/conversion.go rename to pkg/admission/plugin/webhook/generic/conversion.go index a1ba712fc..a75c63fa9 100644 --- a/pkg/admission/plugin/webhook/versioned/conversion.go +++ b/pkg/admission/plugin/webhook/generic/conversion.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package versioned +package generic import ( "fmt" @@ -23,25 +23,13 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -// Convertor converts objects to the desired version. -type Convertor struct { +// convertor converts objects to the desired version. +type convertor struct { Scheme *runtime.Scheme } -// Convert converts the in object to the out object and returns an error if the -// conversion fails. -func (c Convertor) Convert(in runtime.Object, out runtime.Object) error { - // For custom resources, because ConvertToGVK reuses the passed in object as - // the output. c.Scheme.Convert resets the objects to empty if in == out, so - // we skip the conversion if that's the case. - if in == out { - return nil - } - return c.Scheme.Convert(in, out, nil) -} - // ConvertToGVK converts object to the desired gvk. -func (c Convertor) ConvertToGVK(obj runtime.Object, gvk schema.GroupVersionKind) (runtime.Object, error) { +func (c *convertor) ConvertToGVK(obj runtime.Object, gvk schema.GroupVersionKind) (runtime.Object, error) { // Unlike other resources, custom resources do not have internal version, so // if obj is a custom resource, it should not need conversion. if obj.GetObjectKind().GroupVersionKind() == gvk { @@ -59,9 +47,9 @@ func (c Convertor) ConvertToGVK(obj runtime.Object, gvk schema.GroupVersionKind) } // Validate checks if the conversion has a scheme. -func (c *Convertor) Validate() error { +func (c *convertor) Validate() error { if c.Scheme == nil { - return fmt.Errorf("the Convertor requires a scheme") + return fmt.Errorf("the convertor requires a scheme") } return nil } diff --git a/pkg/admission/plugin/webhook/versioned/conversion_test.go b/pkg/admission/plugin/webhook/generic/conversion_test.go similarity index 63% rename from pkg/admission/plugin/webhook/versioned/conversion_test.go rename to pkg/admission/plugin/webhook/generic/conversion_test.go index 1429c71e1..704c24638 100644 --- a/pkg/admission/plugin/webhook/versioned/conversion_test.go +++ b/pkg/admission/plugin/webhook/generic/conversion_test.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package versioned +package generic import ( "reflect" @@ -39,7 +39,7 @@ func initiateScheme() *runtime.Scheme { func TestConvertToGVK(t *testing.T) { scheme := initiateScheme() - c := Convertor{Scheme: scheme} + c := convertor{Scheme: scheme} table := map[string]struct { obj runtime.Object gvk schema.GroupVersionKind @@ -131,89 +131,21 @@ func TestConvertToGVK(t *testing.T) { } } -func TestConvert(t *testing.T) { +// TestRuntimeSchemeConvert verifies that scheme.Convert(x, x, nil) for an unstructured x is a no-op. +// This did not use to be like that and we had to wrap scheme.Convert before. +func TestRuntimeSchemeConvert(t *testing.T) { scheme := initiateScheme() - c := Convertor{Scheme: scheme} - sampleCRD := unstructured.Unstructured{ + obj := &unstructured.Unstructured{ Object: map[string]interface{}{ - "apiVersion": "mygroup.k8s.io/v1", - "kind": "Flunder", - "data": map[string]interface{}{ - "Key": "Value", - }, + "foo": "bar", }, } + clone := obj.DeepCopy() - table := map[string]struct { - in runtime.Object - out runtime.Object - expectedObj runtime.Object - }{ - "convert example/v1#Pod to example#Pod": { - in: &examplev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod1", - Labels: map[string]string{ - "key": "value", - }, - }, - Spec: examplev1.PodSpec{ - RestartPolicy: examplev1.RestartPolicy("never"), - }, - }, - out: &example.Pod{}, - expectedObj: &example.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "pod1", - Labels: map[string]string{ - "key": "value", - }, - }, - Spec: example.PodSpec{ - RestartPolicy: example.RestartPolicy("never"), - }, - }, - }, - "convert example2/v1#replicaset to example#replicaset": { - in: &example2v1.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "rs1", - Labels: map[string]string{ - "key": "value", - }, - }, - Spec: example2v1.ReplicaSetSpec{ - Replicas: func() *int32 { var i int32; i = 1; return &i }(), - }, - }, - out: &example.ReplicaSet{}, - expectedObj: &example.ReplicaSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "rs1", - Labels: map[string]string{ - "key": "value", - }, - }, - Spec: example.ReplicaSetSpec{ - Replicas: 1, - }, - }, - }, - "no conversion if the object is the same": { - in: &sampleCRD, - out: &sampleCRD, - expectedObj: &sampleCRD, - }, + if err := scheme.Convert(obj, obj, nil); err != nil { + t.Fatalf("unexpected convert error: %v", err) } - for name, test := range table { - t.Run(name, func(t *testing.T) { - err := c.Convert(test.in, test.out) - if err != nil { - t.Error(err) - } - if !reflect.DeepEqual(test.out, test.expectedObj) { - t.Errorf("\nexpected:\n%#v\ngot:\n %#v\n", test.expectedObj, test.out) - } - }) + if !reflect.DeepEqual(obj, clone) { + t.Errorf("unexpected mutation of self-converted Unstructured: obj=%#v, clone=%#v", obj, clone) } } diff --git a/pkg/admission/plugin/webhook/generic/interfaces.go b/pkg/admission/plugin/webhook/generic/interfaces.go new file mode 100644 index 000000000..3a7edb526 --- /dev/null +++ b/pkg/admission/plugin/webhook/generic/interfaces.go @@ -0,0 +1,45 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 generic + +import ( + "context" + + "k8s.io/api/admissionregistration/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/admission" +) + +// Source can list dynamic webhook plugins. +type Source interface { + Webhooks() []v1beta1.Webhook + HasSynced() bool +} + +// VersionedAttributes is a wrapper around the original admission attributes, adding versioned +// variants of the object and old object. +type VersionedAttributes struct { + admission.Attributes + VersionedOldObject runtime.Object + VersionedObject runtime.Object +} + +// Dispatcher dispatches webhook call to a list of webhooks with admission attributes as argument. +type Dispatcher interface { + // Dispatch a request to the webhooks using the given webhooks. A non-nil error means the request is rejected. + Dispatch(ctx context.Context, a *VersionedAttributes, hooks []*v1beta1.Webhook) error +} diff --git a/pkg/admission/plugin/webhook/generic/webhook.go b/pkg/admission/plugin/webhook/generic/webhook.go new file mode 100644 index 000000000..fdcbdd9e1 --- /dev/null +++ b/pkg/admission/plugin/webhook/generic/webhook.go @@ -0,0 +1,202 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 generic + +import ( + "context" + "fmt" + "io" + + "k8s.io/api/admissionregistration/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/admission" + genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" + "k8s.io/apiserver/pkg/admission/plugin/webhook/config" + "k8s.io/apiserver/pkg/admission/plugin/webhook/namespace" + "k8s.io/apiserver/pkg/admission/plugin/webhook/rules" + "k8s.io/client-go/informers" + clientset "k8s.io/client-go/kubernetes" +) + +// Webhook is an abstract admission plugin with all the infrastructure to define Admit or Validate on-top. +type Webhook struct { + *admission.Handler + + sourceFactory sourceFactory + + hookSource Source + clientManager *config.ClientManager + convertor *convertor + namespaceMatcher *namespace.Matcher + dispatcher Dispatcher +} + +var ( + _ genericadmissioninit.WantsExternalKubeClientSet = &Webhook{} + _ admission.Interface = &Webhook{} +) + +type sourceFactory func(f informers.SharedInformerFactory) Source +type dispatcherFactory func(cm *config.ClientManager) Dispatcher + +// NewWebhook creates a new generic admission webhook. +func NewWebhook(handler *admission.Handler, configFile io.Reader, sourceFactory sourceFactory, dispatcherFactory dispatcherFactory) (*Webhook, error) { + kubeconfigFile, err := config.LoadConfig(configFile) + if err != nil { + return nil, err + } + + cm, err := config.NewClientManager() + if err != nil { + return nil, err + } + authInfoResolver, err := config.NewDefaultAuthenticationInfoResolver(kubeconfigFile) + if err != nil { + return nil, err + } + // Set defaults which may be overridden later. + cm.SetAuthenticationInfoResolver(authInfoResolver) + cm.SetServiceResolver(config.NewDefaultServiceResolver()) + + return &Webhook{ + Handler: handler, + sourceFactory: sourceFactory, + clientManager: &cm, + convertor: &convertor{}, + namespaceMatcher: &namespace.Matcher{}, + dispatcher: dispatcherFactory(&cm), + }, nil +} + +// SetAuthenticationInfoResolverWrapper sets the +// AuthenticationInfoResolverWrapper. +// TODO find a better way wire this, but keep this pull small for now. +func (a *Webhook) SetAuthenticationInfoResolverWrapper(wrapper config.AuthenticationInfoResolverWrapper) { + a.clientManager.SetAuthenticationInfoResolverWrapper(wrapper) +} + +// SetServiceResolver sets a service resolver for the webhook admission plugin. +// Passing a nil resolver does not have an effect, instead a default one will be used. +func (a *Webhook) SetServiceResolver(sr config.ServiceResolver) { + a.clientManager.SetServiceResolver(sr) +} + +// SetScheme sets a serializer(NegotiatedSerializer) which is derived from the scheme +func (a *Webhook) SetScheme(scheme *runtime.Scheme) { + if scheme != nil { + a.convertor.Scheme = scheme + } +} + +// SetExternalKubeClientSet implements the WantsExternalKubeInformerFactory interface. +// It sets external ClientSet for admission plugins that need it +func (a *Webhook) SetExternalKubeClientSet(client clientset.Interface) { + a.namespaceMatcher.Client = client +} + +// SetExternalKubeInformerFactory implements the WantsExternalKubeInformerFactory interface. +func (a *Webhook) SetExternalKubeInformerFactory(f informers.SharedInformerFactory) { + namespaceInformer := f.Core().V1().Namespaces() + a.namespaceMatcher.NamespaceLister = namespaceInformer.Lister() + a.hookSource = a.sourceFactory(f) + a.SetReadyFunc(func() bool { + return namespaceInformer.Informer().HasSynced() && a.hookSource.HasSynced() + }) +} + +// ValidateInitialization implements the InitializationValidator interface. +func (a *Webhook) ValidateInitialization() error { + if a.hookSource == nil { + return fmt.Errorf("kubernetes client is not properly setup") + } + if err := a.namespaceMatcher.Validate(); err != nil { + return fmt.Errorf("namespaceMatcher is not properly setup: %v", err) + } + if err := a.clientManager.Validate(); err != nil { + return fmt.Errorf("clientManager is not properly setup: %v", err) + } + if err := a.convertor.Validate(); err != nil { + return fmt.Errorf("convertor is not properly setup: %v", err) + } + return nil +} + +// ShouldCallHook makes a decision on whether to call the webhook or not by the attribute. +func (a *Webhook) ShouldCallHook(h *v1beta1.Webhook, attr admission.Attributes) (bool, *apierrors.StatusError) { + var matches bool + for _, r := range h.Rules { + m := rules.Matcher{Rule: r, Attr: attr} + if m.Matches() { + matches = true + break + } + } + if !matches { + return false, nil + } + + return a.namespaceMatcher.MatchNamespaceSelector(h, attr) +} + +// Dispatch is called by the downstream Validate or Admit methods. +func (a *Webhook) Dispatch(attr admission.Attributes) error { + if rules.IsWebhookConfigurationResource(attr) { + return nil + } + if !a.WaitForReady() { + return admission.NewForbidden(attr, fmt.Errorf("not yet ready to handle request")) + } + hooks := a.hookSource.Webhooks() + ctx := context.TODO() + + var relevantHooks []*v1beta1.Webhook + for i := range hooks { + call, err := a.ShouldCallHook(&hooks[i], attr) + if err != nil { + return err + } + if call { + relevantHooks = append(relevantHooks, &hooks[i]) + } + } + + if len(relevantHooks) == 0 { + // no matching hooks + return nil + } + + // convert the object to the external version before sending it to the webhook + versionedAttr := VersionedAttributes{ + Attributes: attr, + } + if oldObj := attr.GetOldObject(); oldObj != nil { + out, err := a.convertor.ConvertToGVK(oldObj, attr.GetKind()) + if err != nil { + return apierrors.NewInternalError(err) + } + versionedAttr.VersionedOldObject = out + } + if obj := attr.GetObject(); obj != nil { + out, err := a.convertor.ConvertToGVK(obj, attr.GetKind()) + if err != nil { + return apierrors.NewInternalError(err) + } + versionedAttr.VersionedObject = out + } + return a.dispatcher.Dispatch(ctx, &versionedAttr, relevantHooks) +} diff --git a/pkg/admission/plugin/webhook/mutating/admission.go b/pkg/admission/plugin/webhook/mutating/admission.go deleted file mode 100644 index 57f82d4d5..000000000 --- a/pkg/admission/plugin/webhook/mutating/admission.go +++ /dev/null @@ -1,311 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 mutating delegates admission checks to dynamically configured -// mutating webhooks. -package mutating - -import ( - "context" - "fmt" - "io" - "time" - - jsonpatch "github.com/evanphx/json-patch" - "github.com/golang/glog" - - admissionv1beta1 "k8s.io/api/admission/v1beta1" - "k8s.io/api/admissionregistration/v1beta1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/serializer/json" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/admission/configuration" - genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" - admissionmetrics "k8s.io/apiserver/pkg/admission/metrics" - "k8s.io/apiserver/pkg/admission/plugin/webhook/config" - webhookerrors "k8s.io/apiserver/pkg/admission/plugin/webhook/errors" - "k8s.io/apiserver/pkg/admission/plugin/webhook/namespace" - "k8s.io/apiserver/pkg/admission/plugin/webhook/request" - "k8s.io/apiserver/pkg/admission/plugin/webhook/rules" - "k8s.io/apiserver/pkg/admission/plugin/webhook/versioned" - "k8s.io/client-go/informers" - clientset "k8s.io/client-go/kubernetes" -) - -const ( - // Name of admission plug-in - PluginName = "MutatingAdmissionWebhook" -) - -// Register registers a plugin -func Register(plugins *admission.Plugins) { - plugins.Register(PluginName, func(configFile io.Reader) (admission.Interface, error) { - plugin, err := NewMutatingWebhook(configFile) - if err != nil { - return nil, err - } - - return plugin, nil - }) -} - -// WebhookSource can list dynamic webhook plugins. -type WebhookSource interface { - Webhooks() *v1beta1.MutatingWebhookConfiguration -} - -// NewMutatingWebhook returns a generic admission webhook plugin. -func NewMutatingWebhook(configFile io.Reader) (*MutatingWebhook, error) { - kubeconfigFile, err := config.LoadConfig(configFile) - if err != nil { - return nil, err - } - - cm, err := config.NewClientManager() - if err != nil { - return nil, err - } - authInfoResolver, err := config.NewDefaultAuthenticationInfoResolver(kubeconfigFile) - if err != nil { - return nil, err - } - // Set defaults which may be overridden later. - cm.SetAuthenticationInfoResolver(authInfoResolver) - cm.SetServiceResolver(config.NewDefaultServiceResolver()) - - return &MutatingWebhook{ - Handler: admission.NewHandler( - admission.Connect, - admission.Create, - admission.Delete, - admission.Update, - ), - clientManager: cm, - }, nil -} - -var _ admission.MutationInterface = &MutatingWebhook{} - -// MutatingWebhook is an implementation of admission.Interface. -type MutatingWebhook struct { - *admission.Handler - hookSource WebhookSource - namespaceMatcher namespace.Matcher - clientManager config.ClientManager - convertor versioned.Convertor - defaulter runtime.ObjectDefaulter - jsonSerializer runtime.Serializer -} - -var ( - _ = genericadmissioninit.WantsExternalKubeClientSet(&MutatingWebhook{}) -) - -// TODO find a better way wire this, but keep this pull small for now. -func (a *MutatingWebhook) SetAuthenticationInfoResolverWrapper(wrapper config.AuthenticationInfoResolverWrapper) { - a.clientManager.SetAuthenticationInfoResolverWrapper(wrapper) -} - -// SetServiceResolver sets a service resolver for the webhook admission plugin. -// Passing a nil resolver does not have an effect, instead a default one will be used. -func (a *MutatingWebhook) SetServiceResolver(sr config.ServiceResolver) { - a.clientManager.SetServiceResolver(sr) -} - -// SetScheme sets a serializer(NegotiatedSerializer) which is derived from the scheme -func (a *MutatingWebhook) SetScheme(scheme *runtime.Scheme) { - if scheme != nil { - a.convertor.Scheme = scheme - a.defaulter = scheme - a.jsonSerializer = json.NewSerializer(json.DefaultMetaFactory, scheme, scheme, false) - } -} - -// WantsExternalKubeClientSet defines a function which sets external ClientSet for admission plugins that need it -func (a *MutatingWebhook) SetExternalKubeClientSet(client clientset.Interface) { - a.namespaceMatcher.Client = client -} - -// SetExternalKubeInformerFactory implements the WantsExternalKubeInformerFactory interface. -func (a *MutatingWebhook) SetExternalKubeInformerFactory(f informers.SharedInformerFactory) { - namespaceInformer := f.Core().V1().Namespaces() - a.namespaceMatcher.NamespaceLister = namespaceInformer.Lister() - mutatingWebhookConfigurationsInformer := f.Admissionregistration().V1beta1().MutatingWebhookConfigurations() - a.hookSource = configuration.NewMutatingWebhookConfigurationManager(mutatingWebhookConfigurationsInformer) - a.SetReadyFunc(func() bool { - return namespaceInformer.Informer().HasSynced() && mutatingWebhookConfigurationsInformer.Informer().HasSynced() - }) -} - -// ValidateInitialization implements the InitializationValidator interface. -func (a *MutatingWebhook) ValidateInitialization() error { - if a.hookSource == nil { - return fmt.Errorf("MutatingWebhook admission plugin requires a Kubernetes client to be provided") - } - if a.jsonSerializer == nil { - return fmt.Errorf("MutatingWebhook admission plugin's jsonSerializer is not properly setup") - } - if err := a.namespaceMatcher.Validate(); err != nil { - return fmt.Errorf("MutatingWebhook.namespaceMatcher is not properly setup: %v", err) - } - if err := a.clientManager.Validate(); err != nil { - return fmt.Errorf("MutatingWebhook.clientManager is not properly setup: %v", err) - } - if err := a.convertor.Validate(); err != nil { - return fmt.Errorf("MutatingWebhook.convertor is not properly setup: %v", err) - } - if a.defaulter == nil { - return fmt.Errorf("MutatingWebhook.defaulter is not properly setup") - } - return nil -} - -func (a *MutatingWebhook) loadConfiguration(attr admission.Attributes) *v1beta1.MutatingWebhookConfiguration { - hookConfig := a.hookSource.Webhooks() - return hookConfig -} - -// Admit makes an admission decision based on the request attributes. -func (a *MutatingWebhook) Admit(attr admission.Attributes) error { - if rules.IsWebhookConfigurationResource(attr) { - return nil - } - - if !a.WaitForReady() { - return admission.NewForbidden(attr, fmt.Errorf("not yet ready to handle request")) - } - - hookConfig := a.loadConfiguration(attr) - hooks := hookConfig.Webhooks - ctx := context.TODO() - - var relevantHooks []*v1beta1.Webhook - for i := range hooks { - call, err := a.shouldCallHook(&hooks[i], attr) - if err != nil { - return err - } - if call { - relevantHooks = append(relevantHooks, &hooks[i]) - } - } - - if len(relevantHooks) == 0 { - // no matching hooks - return nil - } - - // convert the object to the external version before sending it to the webhook - versionedAttr := versioned.Attributes{ - Attributes: attr, - } - if oldObj := attr.GetOldObject(); oldObj != nil { - out, err := a.convertor.ConvertToGVK(oldObj, attr.GetKind()) - if err != nil { - return apierrors.NewInternalError(err) - } - versionedAttr.OldObject = out - } - if obj := attr.GetObject(); obj != nil { - out, err := a.convertor.ConvertToGVK(obj, attr.GetKind()) - if err != nil { - return apierrors.NewInternalError(err) - } - versionedAttr.Object = out - } - - for _, hook := range relevantHooks { - t := time.Now() - err := a.callAttrMutatingHook(ctx, hook, versionedAttr) - admissionmetrics.Metrics.ObserveWebhook(time.Since(t), err != nil, attr, "admit", hook.Name) - if err == nil { - continue - } - - ignoreClientCallFailures := hook.FailurePolicy != nil && *hook.FailurePolicy == v1beta1.Ignore - if callErr, ok := err.(*webhookerrors.ErrCallingWebhook); ok { - if ignoreClientCallFailures { - glog.Warningf("Failed calling webhook, failing open %v: %v", hook.Name, callErr) - utilruntime.HandleError(callErr) - continue - } - glog.Warningf("Failed calling webhook, failing closed %v: %v", hook.Name, err) - } - return apierrors.NewInternalError(err) - } - - // convert attr.Object to the internal version - return a.convertor.Convert(versionedAttr.Object, attr.GetObject()) -} - -// TODO: factor into a common place along with the validating webhook version. -func (a *MutatingWebhook) shouldCallHook(h *v1beta1.Webhook, attr admission.Attributes) (bool, *apierrors.StatusError) { - var matches bool - for _, r := range h.Rules { - m := rules.Matcher{Rule: r, Attr: attr} - if m.Matches() { - matches = true - break - } - } - if !matches { - return false, nil - } - - return a.namespaceMatcher.MatchNamespaceSelector(h, attr) -} - -// note that callAttrMutatingHook updates attr -func (a *MutatingWebhook) callAttrMutatingHook(ctx context.Context, h *v1beta1.Webhook, attr versioned.Attributes) error { - // Make the webhook request - request := request.CreateAdmissionReview(attr) - client, err := a.clientManager.HookClient(h) - if err != nil { - return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} - } - response := &admissionv1beta1.AdmissionReview{} - if err := client.Post().Context(ctx).Body(&request).Do().Into(response); err != nil { - return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} - } - - if !response.Response.Allowed { - return webhookerrors.ToStatusErr(h.Name, response.Response.Result) - } - - patchJS := response.Response.Patch - if len(patchJS) == 0 { - return nil - } - patchObj, err := jsonpatch.DecodePatch(patchJS) - if err != nil { - return apierrors.NewInternalError(err) - } - objJS, err := runtime.Encode(a.jsonSerializer, attr.Object) - if err != nil { - return apierrors.NewInternalError(err) - } - patchedJS, err := patchObj.Apply(objJS) - if err != nil { - return apierrors.NewInternalError(err) - } - if _, _, err := a.jsonSerializer.Decode(patchedJS, nil, attr.Object); err != nil { - return apierrors.NewInternalError(err) - } - a.defaulter.Default(attr.Object) - return nil -} diff --git a/pkg/admission/plugin/webhook/mutating/admission_test.go b/pkg/admission/plugin/webhook/mutating/admission_test.go deleted file mode 100644 index 523e03762..000000000 --- a/pkg/admission/plugin/webhook/mutating/admission_test.go +++ /dev/null @@ -1,653 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 mutating - -import ( - "crypto/tls" - "crypto/x509" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - - "k8s.io/api/admission/v1beta1" - registrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/admission/plugin/webhook/config" - "k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/client-go/rest" -) - -type fakeHookSource struct { - hooks []registrationv1beta1.Webhook - err error -} - -func (f *fakeHookSource) Webhooks() *registrationv1beta1.MutatingWebhookConfiguration { - if f.err != nil { - return nil - } - for i, h := range f.hooks { - if h.NamespaceSelector == nil { - f.hooks[i].NamespaceSelector = &metav1.LabelSelector{} - } - } - return ®istrationv1beta1.MutatingWebhookConfiguration{Webhooks: f.hooks} -} - -func (f *fakeHookSource) Run(stopCh <-chan struct{}) {} - -type fakeServiceResolver struct { - base url.URL -} - -func (f fakeServiceResolver) ResolveEndpoint(namespace, name string) (*url.URL, error) { - if namespace == "failResolve" { - return nil, fmt.Errorf("couldn't resolve service location") - } - u := f.base - return &u, nil -} - -type fakeNamespaceLister struct { - namespaces map[string]*corev1.Namespace -} - -func (f fakeNamespaceLister) List(selector labels.Selector) (ret []*corev1.Namespace, err error) { - return nil, nil -} -func (f fakeNamespaceLister) Get(name string) (*corev1.Namespace, error) { - ns, ok := f.namespaces[name] - if ok { - return ns, nil - } - return nil, errors.NewNotFound(corev1.Resource("namespaces"), name) -} - -// ccfgSVC returns a client config using the service reference mechanism. -func ccfgSVC(urlPath string) registrationv1beta1.WebhookClientConfig { - return registrationv1beta1.WebhookClientConfig{ - Service: ®istrationv1beta1.ServiceReference{ - Name: "webhook-test", - Namespace: "default", - Path: &urlPath, - }, - CABundle: testcerts.CACert, - } -} - -type urlConfigGenerator struct { - baseURL *url.URL -} - -// ccfgURL returns a client config using the URL mechanism. -func (c urlConfigGenerator) ccfgURL(urlPath string) registrationv1beta1.WebhookClientConfig { - u2 := *c.baseURL - u2.Path = urlPath - urlString := u2.String() - return registrationv1beta1.WebhookClientConfig{ - URL: &urlString, - CABundle: testcerts.CACert, - } -} - -// TestAdmit tests that MutatingWebhook#Admit works as expected -func TestAdmit(t *testing.T) { - scheme := runtime.NewScheme() - v1beta1.AddToScheme(scheme) - corev1.AddToScheme(scheme) - - testServer := newTestServer(t) - testServer.StartTLS() - defer testServer.Close() - serverURL, err := url.ParseRequestURI(testServer.URL) - if err != nil { - t.Fatalf("this should never happen? %v", err) - } - wh, err := NewMutatingWebhook(nil) - if err != nil { - t.Fatal(err) - } - cm, err := config.NewClientManager() - if err != nil { - t.Fatalf("cannot create client manager: %v", err) - } - cm.SetAuthenticationInfoResolver(newFakeAuthenticationInfoResolver(new(int32))) - cm.SetServiceResolver(fakeServiceResolver{base: *serverURL}) - wh.clientManager = cm - wh.SetScheme(scheme) - if err = wh.clientManager.Validate(); err != nil { - t.Fatal(err) - } - namespace := "webhook-test" - wh.namespaceMatcher.NamespaceLister = fakeNamespaceLister{map[string]*corev1.Namespace{ - namespace: { - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "runlevel": "0", - }, - }, - }, - }, - } - - // Set up a test object for the call - kind := corev1.SchemeGroupVersion.WithKind("Pod") - name := "my-pod" - object := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "pod.name": name, - }, - Name: name, - Namespace: namespace, - }, - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Pod", - }, - } - oldObject := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - } - operation := admission.Update - resource := corev1.Resource("pods").WithVersion("v1") - subResource := "" - userInfo := user.DefaultInfo{ - Name: "webhook-test", - UID: "webhook-test", - } - - ccfgURL := urlConfigGenerator{serverURL}.ccfgURL - - type test struct { - hookSource fakeHookSource - path string - expectAllow bool - errorContains string - } - - matchEverythingRules := []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, - Rule: registrationv1beta1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }, - }} - - policyFail := registrationv1beta1.Fail - policyIgnore := registrationv1beta1.Ignore - - table := map[string]test{ - "no match": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "nomatch", - ClientConfig: ccfgSVC("disallow"), - Rules: []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.Create}, - }}, - }}, - }, - expectAllow: true, - }, - "match & allow": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "allow", - ClientConfig: ccfgSVC("allow"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: true, - }, - "match & disallow": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "without explanation", - }, - "match & disallow ii": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallowReason", - ClientConfig: ccfgSVC("disallowReason"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "you shall not pass", - }, - "match & disallow & but allowed because namespaceSelector exempt the namespace": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: newMatchEverythingRules(), - NamespaceSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "runlevel", - Values: []string{"1"}, - Operator: metav1.LabelSelectorOpIn, - }}, - }, - }}, - }, - expectAllow: true, - }, - "match & disallow & but allowed because namespaceSelector exempt the namespace ii": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: newMatchEverythingRules(), - NamespaceSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "runlevel", - Values: []string{"0"}, - Operator: metav1.LabelSelectorOpNotIn, - }}, - }, - }}, - }, - expectAllow: true, - }, - "match & fail (but allow because fail open)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - }, - "match & fail (but disallow because fail closed on nil)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: false, - }, - "match & fail (but fail because fail closed)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }}, - }, - expectAllow: false, - }, - "match & allow (url)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "allow", - ClientConfig: ccfgURL("allow"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: true, - }, - "match & disallow (url)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgURL("disallow"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "without explanation", - }, - // No need to test everything with the url case, since only the - // connection is different. - } - - for name, tt := range table { - if !strings.Contains(name, "no match") { - continue - } - t.Run(name, func(t *testing.T) { - wh.hookSource = &tt.hookSource - err = wh.Admit(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, name, resource, subResource, operation, &userInfo)) - if tt.expectAllow != (err == nil) { - t.Errorf("expected allowed=%v, but got err=%v", tt.expectAllow, err) - } - // ErrWebhookRejected is not an error for our purposes - if tt.errorContains != "" { - if err == nil || !strings.Contains(err.Error(), tt.errorContains) { - t.Errorf(" expected an error saying %q, but got %v", tt.errorContains, err) - } - } - if _, isStatusErr := err.(*errors.StatusError); err != nil && !isStatusErr { - t.Errorf("%s: expected a StatusError, got %T", name, err) - } - }) - } -} - -// TestAdmitCachedClient tests that MutatingWebhook#Admit should cache restClient -func TestAdmitCachedClient(t *testing.T) { - scheme := runtime.NewScheme() - v1beta1.AddToScheme(scheme) - corev1.AddToScheme(scheme) - - testServer := newTestServer(t) - testServer.StartTLS() - defer testServer.Close() - serverURL, err := url.ParseRequestURI(testServer.URL) - if err != nil { - t.Fatalf("this should never happen? %v", err) - } - wh, err := NewMutatingWebhook(nil) - if err != nil { - t.Fatal(err) - } - cm, err := config.NewClientManager() - if err != nil { - t.Fatalf("cannot create client manager: %v", err) - } - cm.SetServiceResolver(fakeServiceResolver{base: *serverURL}) - wh.clientManager = cm - wh.SetScheme(scheme) - namespace := "webhook-test" - wh.namespaceMatcher.NamespaceLister = fakeNamespaceLister{map[string]*corev1.Namespace{ - namespace: { - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "runlevel": "0", - }, - }, - }, - }, - } - - // Set up a test object for the call - kind := corev1.SchemeGroupVersion.WithKind("Pod") - name := "my-pod" - object := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "pod.name": name, - }, - Name: name, - Namespace: namespace, - }, - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Pod", - }, - } - oldObject := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - } - operation := admission.Update - resource := corev1.Resource("pods").WithVersion("v1") - subResource := "" - userInfo := user.DefaultInfo{ - Name: "webhook-test", - UID: "webhook-test", - } - ccfgURL := urlConfigGenerator{serverURL}.ccfgURL - - type test struct { - name string - hookSource fakeHookSource - expectAllow bool - expectCache bool - } - - policyIgnore := registrationv1beta1.Ignore - cases := []test{ - { - name: "cache 1", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache1", - ClientConfig: ccfgSVC("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 2", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache2", - ClientConfig: ccfgSVC("internalErr"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 3", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache3", - ClientConfig: ccfgSVC("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: false, - }, - { - name: "cache 4", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache4", - ClientConfig: ccfgURL("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 5", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache5", - ClientConfig: ccfgURL("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: false, - }, - } - - for _, testcase := range cases { - t.Run(testcase.name, func(t *testing.T) { - wh.hookSource = &testcase.hookSource - authInfoResolverCount := new(int32) - r := newFakeAuthenticationInfoResolver(authInfoResolverCount) - wh.clientManager.SetAuthenticationInfoResolver(r) - if err = wh.clientManager.Validate(); err != nil { - t.Fatal(err) - } - - err = wh.Admit(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, testcase.name, resource, subResource, operation, &userInfo)) - if testcase.expectAllow != (err == nil) { - t.Errorf("expected allowed=%v, but got err=%v", testcase.expectAllow, err) - } - - if testcase.expectCache && *authInfoResolverCount != 1 { - t.Errorf("expected cacheclient, but got none") - } - - if !testcase.expectCache && *authInfoResolverCount != 0 { - t.Errorf("expected not cacheclient, but got cache") - } - }) - } - -} - -func newTestServer(t *testing.T) *httptest.Server { - // Create the test webhook server - sCert, err := tls.X509KeyPair(testcerts.ServerCert, testcerts.ServerKey) - if err != nil { - t.Fatal(err) - } - rootCAs := x509.NewCertPool() - rootCAs.AppendCertsFromPEM(testcerts.CACert) - testServer := httptest.NewUnstartedServer(http.HandlerFunc(webhookHandler)) - testServer.TLS = &tls.Config{ - Certificates: []tls.Certificate{sCert}, - ClientCAs: rootCAs, - ClientAuth: tls.RequireAndVerifyClientCert, - } - return testServer -} - -func webhookHandler(w http.ResponseWriter, r *http.Request) { - fmt.Printf("got req: %v\n", r.URL.Path) - switch r.URL.Path { - case "/internalErr": - http.Error(w, "webhook internal server error", http.StatusInternalServerError) - return - case "/invalidReq": - w.WriteHeader(http.StatusSwitchingProtocols) - w.Write([]byte("webhook invalid request")) - return - case "/invalidResp": - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("webhook invalid response")) - case "/disallow": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: false, - }, - }) - case "/disallowReason": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: false, - Result: &metav1.Status{ - Message: "you shall not pass", - }, - }, - }) - case "/allow": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: true, - }, - }) - default: - http.NotFound(w, r) - } -} - -func newFakeAuthenticationInfoResolver(count *int32) *fakeAuthenticationInfoResolver { - return &fakeAuthenticationInfoResolver{ - restConfig: &rest.Config{ - TLSClientConfig: rest.TLSClientConfig{ - CAData: testcerts.CACert, - CertData: testcerts.ClientCert, - KeyData: testcerts.ClientKey, - }, - }, - cachedCount: count, - } -} - -type fakeAuthenticationInfoResolver struct { - restConfig *rest.Config - cachedCount *int32 -} - -func (c *fakeAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { - atomic.AddInt32(c.cachedCount, 1) - return c.restConfig, nil -} - -func (c *fakeAuthenticationInfoResolver) ClientConfigForService(serviceName, serviceNamespace string) (*rest.Config, error) { - atomic.AddInt32(c.cachedCount, 1) - return c.restConfig, nil -} - -func newMatchEverythingRules() []registrationv1beta1.RuleWithOperations { - return []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, - Rule: registrationv1beta1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }, - }} -} diff --git a/pkg/admission/plugin/webhook/mutating/dispatcher.go b/pkg/admission/plugin/webhook/mutating/dispatcher.go new file mode 100644 index 000000000..f933142e8 --- /dev/null +++ b/pkg/admission/plugin/webhook/mutating/dispatcher.go @@ -0,0 +1,118 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 mutating delegates admission checks to dynamically configured +// mutating webhooks. +package mutating + +import ( + "context" + "time" + + jsonpatch "github.com/evanphx/json-patch" + "github.com/golang/glog" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + "k8s.io/api/admissionregistration/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + admissionmetrics "k8s.io/apiserver/pkg/admission/metrics" + "k8s.io/apiserver/pkg/admission/plugin/webhook/config" + webhookerrors "k8s.io/apiserver/pkg/admission/plugin/webhook/errors" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/admission/plugin/webhook/request" +) + +type mutatingDispatcher struct { + cm *config.ClientManager + plugin *Plugin +} + +func newMutatingDispatcher(p *Plugin) func(cm *config.ClientManager) generic.Dispatcher { + return func(cm *config.ClientManager) generic.Dispatcher { + return &mutatingDispatcher{cm, p} + } +} + +var _ generic.Dispatcher = &mutatingDispatcher{} + +func (a *mutatingDispatcher) Dispatch(ctx context.Context, attr *generic.VersionedAttributes, relevantHooks []*v1beta1.Webhook) error { + for _, hook := range relevantHooks { + t := time.Now() + err := a.callAttrMutatingHook(ctx, hook, attr) + admissionmetrics.Metrics.ObserveWebhook(time.Since(t), err != nil, attr.Attributes, "admit", hook.Name) + if err == nil { + continue + } + + ignoreClientCallFailures := hook.FailurePolicy != nil && *hook.FailurePolicy == v1beta1.Ignore + if callErr, ok := err.(*webhookerrors.ErrCallingWebhook); ok { + if ignoreClientCallFailures { + glog.Warningf("Failed calling webhook, failing open %v: %v", hook.Name, callErr) + utilruntime.HandleError(callErr) + continue + } + glog.Warningf("Failed calling webhook, failing closed %v: %v", hook.Name, err) + } + return apierrors.NewInternalError(err) + } + + // convert attr.VersionedObject to the internal version in the underlying admission.Attributes + return a.plugin.scheme.Convert(attr.VersionedObject, attr.Attributes.GetObject(), nil) +} + +// note that callAttrMutatingHook updates attr +func (a *mutatingDispatcher) callAttrMutatingHook(ctx context.Context, h *v1beta1.Webhook, attr *generic.VersionedAttributes) error { + // Make the webhook request + request := request.CreateAdmissionReview(attr) + client, err := a.cm.HookClient(h) + if err != nil { + return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} + } + response := &admissionv1beta1.AdmissionReview{} + if err := client.Post().Context(ctx).Body(&request).Do().Into(response); err != nil { + return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} + } + + if !response.Response.Allowed { + return webhookerrors.ToStatusErr(h.Name, response.Response.Result) + } + + patchJS := response.Response.Patch + if len(patchJS) == 0 { + return nil + } + patchObj, err := jsonpatch.DecodePatch(patchJS) + if err != nil { + return apierrors.NewInternalError(err) + } + objJS, err := runtime.Encode(a.plugin.jsonSerializer, attr.VersionedObject) + if err != nil { + return apierrors.NewInternalError(err) + } + patchedJS, err := patchObj.Apply(objJS) + if err != nil { + return apierrors.NewInternalError(err) + } + // TODO: if we have multiple mutating webhooks, we can remember the json + // instead of encoding and decoding for each one. + if _, _, err := a.plugin.jsonSerializer.Decode(patchedJS, nil, attr.VersionedObject); err != nil { + return apierrors.NewInternalError(err) + } + a.plugin.scheme.Default(attr.VersionedObject) + return nil +} diff --git a/pkg/admission/plugin/webhook/mutating/dispatcher_test.go b/pkg/admission/plugin/webhook/mutating/dispatcher_test.go new file mode 100644 index 000000000..874f43a71 --- /dev/null +++ b/pkg/admission/plugin/webhook/mutating/dispatcher_test.go @@ -0,0 +1,136 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 mutating + +import ( + "context" + "reflect" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + "k8s.io/apiserver/pkg/apis/example" + examplev1 "k8s.io/apiserver/pkg/apis/example/v1" + example2v1 "k8s.io/apiserver/pkg/apis/example2/v1" +) + +var sampleCRD = unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "mygroup.k8s.io/v1", + "kind": "Flunder", + "data": map[string]interface{}{ + "Key": "Value", + }, + }, +} + +func TestDispatch(t *testing.T) { + scheme := runtime.NewScheme() + example.AddToScheme(scheme) + examplev1.AddToScheme(scheme) + example2v1.AddToScheme(scheme) + + tests := []struct { + name string + in runtime.Object + out runtime.Object + expectedObj runtime.Object + }{ + { + name: "convert example/v1#Pod to example#Pod", + in: &examplev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Labels: map[string]string{ + "key": "value", + }, + }, + Spec: examplev1.PodSpec{ + RestartPolicy: examplev1.RestartPolicy("never"), + }, + }, + out: &example.Pod{}, + expectedObj: &example.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "pod1", + Labels: map[string]string{ + "key": "value", + }, + }, + Spec: example.PodSpec{ + RestartPolicy: example.RestartPolicy("never"), + }, + }, + }, + { + name: "convert example2/v1#replicaset to example#replicaset", + in: &example2v1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rs1", + Labels: map[string]string{ + "key": "value", + }, + }, + Spec: example2v1.ReplicaSetSpec{ + Replicas: func() *int32 { var i int32; i = 1; return &i }(), + }, + }, + out: &example.ReplicaSet{}, + expectedObj: &example.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "rs1", + Labels: map[string]string{ + "key": "value", + }, + }, + Spec: example.ReplicaSetSpec{ + Replicas: 1, + }, + }, + }, + { + name: "no conversion if the object is the same", + in: &sampleCRD, + out: &sampleCRD, + expectedObj: &sampleCRD, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + a := &mutatingDispatcher{ + plugin: &Plugin{ + scheme: scheme, + }, + } + attr := generic.VersionedAttributes{ + Attributes: admission.NewAttributesRecord(test.out, nil, schema.GroupVersionKind{}, "", "", schema.GroupVersionResource{}, "", admission.Operation(""), nil), + VersionedOldObject: nil, + VersionedObject: test.in, + } + if err := a.Dispatch(context.TODO(), &attr, nil); err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if !reflect.DeepEqual(attr.Attributes.GetObject(), test.expectedObj) { + t.Errorf("\nexpected:\n%#v\ngot:\n %#v\n", test.expectedObj, test.out) + } + }) + } +} diff --git a/pkg/admission/plugin/webhook/mutating/plugin.go b/pkg/admission/plugin/webhook/mutating/plugin.go new file mode 100644 index 000000000..f03b1b342 --- /dev/null +++ b/pkg/admission/plugin/webhook/mutating/plugin.go @@ -0,0 +1,96 @@ +/* +Copyright 2017 The Kubernetes Authors. + +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 mutating + +import ( + "fmt" + "io" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/configuration" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" +) + +const ( + // Name of admission plug-in + PluginName = "MutatingAdmissionWebhook" +) + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(configFile io.Reader) (admission.Interface, error) { + plugin, err := NewMutatingWebhook(configFile) + if err != nil { + return nil, err + } + + return plugin, nil + }) +} + +// Plugin is an implementation of admission.Interface. +type Plugin struct { + *generic.Webhook + + scheme *runtime.Scheme + jsonSerializer *json.Serializer +} + +var _ admission.MutationInterface = &Plugin{} + +// NewMutatingWebhook returns a generic admission webhook plugin. +func NewMutatingWebhook(configFile io.Reader) (*Plugin, error) { + handler := admission.NewHandler(admission.Connect, admission.Create, admission.Delete, admission.Update) + p := &Plugin{} + var err error + p.Webhook, err = generic.NewWebhook(handler, configFile, configuration.NewMutatingWebhookConfigurationManager, newMutatingDispatcher(p)) + if err != nil { + return nil, err + } + + return p, nil +} + +// SetScheme sets a serializer(NegotiatedSerializer) which is derived from the scheme +func (a *Plugin) SetScheme(scheme *runtime.Scheme) { + a.Webhook.SetScheme(scheme) + if scheme != nil { + a.scheme = scheme + a.jsonSerializer = json.NewSerializer(json.DefaultMetaFactory, scheme, scheme, false) + } +} + +// ValidateInitialization implements the InitializationValidator interface. +func (a *Plugin) ValidateInitialization() error { + if err := a.Webhook.ValidateInitialization(); err != nil { + return err + } + if a.scheme == nil { + return fmt.Errorf("scheme is not properly setup") + } + if a.jsonSerializer == nil { + return fmt.Errorf("jsonSerializer is not properly setup") + } + return nil +} + +// Admit makes an admission decision based on the request attributes. +func (a *Plugin) Admit(attr admission.Attributes) error { + return a.Webhook.Dispatch(attr) +} diff --git a/pkg/admission/plugin/webhook/mutating/plugin_test.go b/pkg/admission/plugin/webhook/mutating/plugin_test.go new file mode 100644 index 000000000..82c4e3972 --- /dev/null +++ b/pkg/admission/plugin/webhook/mutating/plugin_test.go @@ -0,0 +1,143 @@ +/* +Copyright 2017 The Kubernetes Authors. + +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 mutating + +import ( + "net/url" + "strings" + "testing" + + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + webhooktesting "k8s.io/apiserver/pkg/admission/plugin/webhook/testing" +) + +// TestAdmit tests that MutatingWebhook#Admit works as expected +func TestAdmit(t *testing.T) { + scheme := runtime.NewScheme() + v1beta1.AddToScheme(scheme) + corev1.AddToScheme(scheme) + + testServer := webhooktesting.NewTestServer(t) + testServer.StartTLS() + defer testServer.Close() + serverURL, err := url.ParseRequestURI(testServer.URL) + if err != nil { + t.Fatalf("this should never happen? %v", err) + } + + stopCh := make(chan struct{}) + defer close(stopCh) + + for _, tt := range webhooktesting.NewTestCases(serverURL) { + wh, err := NewMutatingWebhook(nil) + if err != nil { + t.Errorf("%s: failed to create mutating webhook: %v", tt.Name, err) + continue + } + + ns := "webhook-test" + client, informer := webhooktesting.NewFakeDataSource(ns, tt.Webhooks, true, stopCh) + + wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(new(int32)))) + wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL)) + wh.SetScheme(scheme) + wh.SetExternalKubeClientSet(client) + wh.SetExternalKubeInformerFactory(informer) + + informer.Start(stopCh) + informer.WaitForCacheSync(stopCh) + + if err = wh.ValidateInitialization(); err != nil { + t.Errorf("%s: failed to validate initialization: %v", tt.Name, err) + continue + } + + err = wh.Admit(webhooktesting.NewAttribute(ns)) + if tt.ExpectAllow != (err == nil) { + t.Errorf("%s: expected allowed=%v, but got err=%v", tt.Name, tt.ExpectAllow, err) + } + // ErrWebhookRejected is not an error for our purposes + if tt.ErrorContains != "" { + if err == nil || !strings.Contains(err.Error(), tt.ErrorContains) { + t.Errorf("%s: expected an error saying %q, but got: %v", tt.Name, tt.ErrorContains, err) + } + } + if _, isStatusErr := err.(*errors.StatusError); err != nil && !isStatusErr { + t.Errorf("%s: expected a StatusError, got %T", tt.Name, err) + } + } +} + +// TestAdmitCachedClient tests that MutatingWebhook#Admit should cache restClient +func TestAdmitCachedClient(t *testing.T) { + scheme := runtime.NewScheme() + v1beta1.AddToScheme(scheme) + corev1.AddToScheme(scheme) + + testServer := webhooktesting.NewTestServer(t) + testServer.StartTLS() + defer testServer.Close() + serverURL, err := url.ParseRequestURI(testServer.URL) + if err != nil { + t.Fatalf("this should never happen? %v", err) + } + + stopCh := make(chan struct{}) + defer close(stopCh) + + wh, err := NewMutatingWebhook(nil) + if err != nil { + t.Fatalf("Failed to create mutating webhook: %v", err) + } + wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL)) + wh.SetScheme(scheme) + + for _, tt := range webhooktesting.NewCachedClientTestcases(serverURL) { + ns := "webhook-test" + client, informer := webhooktesting.NewFakeDataSource(ns, tt.Webhooks, true, stopCh) + + // override the webhook source. The client cache will stay the same. + cacheMisses := new(int32) + wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(cacheMisses))) + wh.SetExternalKubeClientSet(client) + wh.SetExternalKubeInformerFactory(informer) + + informer.Start(stopCh) + informer.WaitForCacheSync(stopCh) + + if err = wh.ValidateInitialization(); err != nil { + t.Errorf("%s: failed to validate initialization: %v", tt.Name, err) + continue + } + + err = wh.Admit(webhooktesting.NewAttribute(ns)) + if tt.ExpectAllow != (err == nil) { + t.Errorf("%s: expected allowed=%v, but got err=%v", tt.Name, tt.ExpectAllow, err) + } + + if tt.ExpectCacheMiss && *cacheMisses == 0 { + t.Errorf("%s: expected cache miss, but got no AuthenticationInfoResolver call", tt.Name) + } + + if !tt.ExpectCacheMiss && *cacheMisses > 0 { + t.Errorf("%s: expected client to be cached, but got %d AuthenticationInfoResolver calls", tt.Name, *cacheMisses) + } + } +} diff --git a/pkg/admission/plugin/webhook/request/admissionreview.go b/pkg/admission/plugin/webhook/request/admissionreview.go index 5b8a41db2..663349a4e 100644 --- a/pkg/admission/plugin/webhook/request/admissionreview.go +++ b/pkg/admission/plugin/webhook/request/admissionreview.go @@ -22,11 +22,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/uuid" - "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" ) // CreateAdmissionReview creates an AdmissionReview for the provided admission.Attributes -func CreateAdmissionReview(attr admission.Attributes) admissionv1beta1.AdmissionReview { +func CreateAdmissionReview(attr *generic.VersionedAttributes) admissionv1beta1.AdmissionReview { gvk := attr.GetKind() gvr := attr.GetResource() aUserInfo := attr.GetUserInfo() @@ -61,10 +61,10 @@ func CreateAdmissionReview(attr admission.Attributes) admissionv1beta1.Admission Operation: admissionv1beta1.Operation(attr.GetOperation()), UserInfo: userInfo, Object: runtime.RawExtension{ - Object: attr.GetObject(), + Object: attr.VersionedObject, }, OldObject: runtime.RawExtension{ - Object: attr.GetOldObject(), + Object: attr.VersionedOldObject, }, }, } diff --git a/pkg/admission/plugin/webhook/testing/authentication_info_resolver.go b/pkg/admission/plugin/webhook/testing/authentication_info_resolver.go new file mode 100644 index 000000000..0178f4182 --- /dev/null +++ b/pkg/admission/plugin/webhook/testing/authentication_info_resolver.go @@ -0,0 +1,63 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 testing + +import ( + "sync/atomic" + + "k8s.io/apiserver/pkg/admission/plugin/webhook/config" + "k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts" + "k8s.io/client-go/rest" +) + +// Wrapper turns an AuthenticationInfoResolver into a AuthenticationInfoResolverWrapper that unconditionally +// returns the given AuthenticationInfoResolver. +func Wrapper(r config.AuthenticationInfoResolver) func(config.AuthenticationInfoResolver) config.AuthenticationInfoResolver { + return func(config.AuthenticationInfoResolver) config.AuthenticationInfoResolver { + return r + } +} + +// NewAuthenticationInfoResolver creates a fake AuthenticationInfoResolver that counts cache misses on +// every call to its methods. +func NewAuthenticationInfoResolver(cacheMisses *int32) config.AuthenticationInfoResolver { + return &authenticationInfoResolver{ + restConfig: &rest.Config{ + TLSClientConfig: rest.TLSClientConfig{ + CAData: testcerts.CACert, + CertData: testcerts.ClientCert, + KeyData: testcerts.ClientKey, + }, + }, + cacheMisses: cacheMisses, + } +} + +type authenticationInfoResolver struct { + restConfig *rest.Config + cacheMisses *int32 +} + +func (a *authenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { + atomic.AddInt32(a.cacheMisses, 1) + return a.restConfig, nil +} + +func (a *authenticationInfoResolver) ClientConfigForService(serviceName, serviceNamespace string) (*rest.Config, error) { + atomic.AddInt32(a.cacheMisses, 1) + return a.restConfig, nil +} diff --git a/pkg/admission/plugin/webhook/testing/service_resolver.go b/pkg/admission/plugin/webhook/testing/service_resolver.go new file mode 100644 index 000000000..312535cea --- /dev/null +++ b/pkg/admission/plugin/webhook/testing/service_resolver.go @@ -0,0 +1,42 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 testing + +import ( + "fmt" + "net/url" + + "k8s.io/apiserver/pkg/admission/plugin/webhook/config" +) + +type serviceResolver struct { + base url.URL +} + +// NewServiceResolver returns a static service resolve that return the given URL or +// an error for the failResolve namespace. +func NewServiceResolver(base url.URL) config.ServiceResolver { + return &serviceResolver{base} +} + +func (f serviceResolver) ResolveEndpoint(namespace, name string) (*url.URL, error) { + if namespace == "failResolve" { + return nil, fmt.Errorf("couldn't resolve service location") + } + u := f.base + return &u, nil +} diff --git a/pkg/admission/plugin/webhook/testing/testcase.go b/pkg/admission/plugin/webhook/testing/testcase.go new file mode 100644 index 000000000..eb74a58f2 --- /dev/null +++ b/pkg/admission/plugin/webhook/testing/testcase.go @@ -0,0 +1,426 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 testing + +import ( + "net/url" + + registrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + fakeclientset "k8s.io/client-go/kubernetes/fake" +) + +var matchEverythingRules = []registrationv1beta1.RuleWithOperations{{ + Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, + Rule: registrationv1beta1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }, +}} + +// NewFakeDataSource returns a mock client and informer returning the given webhooks. +func NewFakeDataSource(name string, webhooks []registrationv1beta1.Webhook, mutating bool, stopCh <-chan struct{}) (clientset kubernetes.Interface, factory informers.SharedInformerFactory) { + var objs = []runtime.Object{ + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + "runlevel": "0", + }, + }, + }, + } + if mutating { + objs = append(objs, ®istrationv1beta1.MutatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhooks", + }, + Webhooks: webhooks, + }) + } else { + objs = append(objs, ®istrationv1beta1.ValidatingWebhookConfiguration{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-webhooks", + }, + Webhooks: webhooks, + }) + } + + client := fakeclientset.NewSimpleClientset(objs...) + informerFactory := informers.NewSharedInformerFactory(client, 0) + + return client, informerFactory +} + +// NewAttribute returns static admission Attributes for testing. +func NewAttribute(namespace string) admission.Attributes { + // Set up a test object for the call + kind := corev1.SchemeGroupVersion.WithKind("Pod") + name := "my-pod" + object := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "pod.name": name, + }, + Name: name, + Namespace: namespace, + }, + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "Pod", + }, + } + oldObject := corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + } + operation := admission.Update + resource := corev1.Resource("pods").WithVersion("v1") + subResource := "" + userInfo := user.DefaultInfo{ + Name: "webhook-test", + UID: "webhook-test", + } + + return admission.NewAttributesRecord(&object, &oldObject, kind, namespace, name, resource, subResource, operation, &userInfo) +} + +type urlConfigGenerator struct { + baseURL *url.URL +} + +func (c urlConfigGenerator) ccfgURL(urlPath string) registrationv1beta1.WebhookClientConfig { + u2 := *c.baseURL + u2.Path = urlPath + urlString := u2.String() + return registrationv1beta1.WebhookClientConfig{ + URL: &urlString, + CABundle: testcerts.CACert, + } +} + +// Test is a webhook test case. +type Test struct { + Name string + Webhooks []registrationv1beta1.Webhook + Path string + ExpectAllow bool + ErrorContains string +} + +// NewTestCases returns test cases with a given base url. +func NewTestCases(url *url.URL) []Test { + policyFail := registrationv1beta1.Fail + policyIgnore := registrationv1beta1.Ignore + ccfgURL := urlConfigGenerator{url}.ccfgURL + + return []Test{ + { + Name: "no match", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "nomatch", + ClientConfig: ccfgSVC("disallow"), + Rules: []registrationv1beta1.RuleWithOperations{{ + Operations: []registrationv1beta1.OperationType{registrationv1beta1.Create}, + }}, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ExpectAllow: true, + }, + { + Name: "match & allow", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "allow", + ClientConfig: ccfgSVC("allow"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ExpectAllow: true, + }, + { + Name: "match & disallow", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "disallow", + ClientConfig: ccfgSVC("disallow"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ErrorContains: "without explanation", + }, + { + Name: "match & disallow ii", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "disallowReason", + ClientConfig: ccfgSVC("disallowReason"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + + ErrorContains: "you shall not pass", + }, + { + Name: "match & disallow & but allowed because namespaceSelector exempt the ns", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "disallow", + ClientConfig: ccfgSVC("disallow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "runlevel", + Values: []string{"1"}, + Operator: metav1.LabelSelectorOpIn, + }}, + }, + }}, + + ExpectAllow: true, + }, + { + Name: "match & disallow & but allowed because namespaceSelector exempt the ns ii", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "disallow", + ClientConfig: ccfgSVC("disallow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{ + Key: "runlevel", + Values: []string{"0"}, + Operator: metav1.LabelSelectorOpNotIn, + }}, + }, + }}, + ExpectAllow: true, + }, + { + Name: "match & fail (but allow because fail open)", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "internalErr A", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }, { + Name: "internalErr B", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }, { + Name: "internalErr C", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + + ExpectAllow: true, + }, + { + Name: "match & fail (but disallow because fail close on nil FailurePolicy)", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "internalErr A", + ClientConfig: ccfgSVC("internalErr"), + NamespaceSelector: &metav1.LabelSelector{}, + Rules: matchEverythingRules, + }, { + Name: "internalErr B", + ClientConfig: ccfgSVC("internalErr"), + NamespaceSelector: &metav1.LabelSelector{}, + Rules: matchEverythingRules, + }, { + Name: "internalErr C", + ClientConfig: ccfgSVC("internalErr"), + NamespaceSelector: &metav1.LabelSelector{}, + Rules: matchEverythingRules, + }}, + ExpectAllow: false, + }, + { + Name: "match & fail (but fail because fail closed)", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "internalErr A", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyFail, + }, { + Name: "internalErr B", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyFail, + }, { + Name: "internalErr C", + ClientConfig: ccfgSVC("internalErr"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyFail, + }}, + ExpectAllow: false, + }, + { + Name: "match & allow (url)", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "allow", + ClientConfig: ccfgURL("allow"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ExpectAllow: true, + }, + { + Name: "match & disallow (url)", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "disallow", + ClientConfig: ccfgURL("disallow"), + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ErrorContains: "without explanation", + }, { + Name: "absent response and fail open", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "nilResponse", + ClientConfig: ccfgURL("nilResponse"), + FailurePolicy: &policyIgnore, + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ExpectAllow: true, + }, + { + Name: "absent response and fail closed", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "nilResponse", + ClientConfig: ccfgURL("nilResponse"), + FailurePolicy: &policyFail, + Rules: matchEverythingRules, + NamespaceSelector: &metav1.LabelSelector{}, + }}, + ErrorContains: "Webhook response was absent", + }, + // No need to test everything with the url case, since only the + // connection is different. + } +} + +// CachedTest is a test case for the client manager. +type CachedTest struct { + Name string + Webhooks []registrationv1beta1.Webhook + ExpectAllow bool + ExpectCacheMiss bool +} + +// NewCachedClientTestcases returns a set of client manager test cases. +func NewCachedClientTestcases(url *url.URL) []CachedTest { + policyIgnore := registrationv1beta1.Ignore + ccfgURL := urlConfigGenerator{url}.ccfgURL + + return []CachedTest{ + { + Name: "uncached: service webhook, path 'allow'", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "cache1", + ClientConfig: ccfgSVC("allow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + ExpectAllow: true, + ExpectCacheMiss: true, + }, + { + Name: "uncached: service webhook, path 'internalErr'", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "cache2", + ClientConfig: ccfgSVC("internalErr"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + ExpectAllow: true, + ExpectCacheMiss: true, + }, + { + Name: "cached: service webhook, path 'allow'", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "cache3", + ClientConfig: ccfgSVC("allow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + ExpectAllow: true, + ExpectCacheMiss: false, + }, + { + Name: "uncached: url webhook, path 'allow'", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "cache4", + ClientConfig: ccfgURL("allow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + ExpectAllow: true, + ExpectCacheMiss: true, + }, + { + Name: "cached: service webhook, path 'allow'", + Webhooks: []registrationv1beta1.Webhook{{ + Name: "cache5", + ClientConfig: ccfgURL("allow"), + Rules: newMatchEverythingRules(), + NamespaceSelector: &metav1.LabelSelector{}, + FailurePolicy: &policyIgnore, + }}, + ExpectAllow: true, + ExpectCacheMiss: false, + }, + } +} + +// ccfgSVC returns a client config using the service reference mechanism. +func ccfgSVC(urlPath string) registrationv1beta1.WebhookClientConfig { + return registrationv1beta1.WebhookClientConfig{ + Service: ®istrationv1beta1.ServiceReference{ + Name: "webhook-test", + Namespace: "default", + Path: &urlPath, + }, + CABundle: testcerts.CACert, + } +} + +func newMatchEverythingRules() []registrationv1beta1.RuleWithOperations { + return []registrationv1beta1.RuleWithOperations{{ + Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, + Rule: registrationv1beta1.Rule{ + APIGroups: []string{"*"}, + APIVersions: []string{"*"}, + Resources: []string{"*/*"}, + }, + }} +} diff --git a/pkg/admission/plugin/webhook/testing/webhook_server.go b/pkg/admission/plugin/webhook/testing/webhook_server.go new file mode 100644 index 000000000..1736b4673 --- /dev/null +++ b/pkg/admission/plugin/webhook/testing/webhook_server.go @@ -0,0 +1,94 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 testing + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "k8s.io/api/admission/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts" +) + +// NewTestServer returns a webhook test HTTPS server with fixed webhook test certs. +func NewTestServer(t *testing.T) *httptest.Server { + // Create the test webhook server + sCert, err := tls.X509KeyPair(testcerts.ServerCert, testcerts.ServerKey) + if err != nil { + t.Fatal(err) + } + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM(testcerts.CACert) + testServer := httptest.NewUnstartedServer(http.HandlerFunc(webhookHandler)) + testServer.TLS = &tls.Config{ + Certificates: []tls.Certificate{sCert}, + ClientCAs: rootCAs, + ClientAuth: tls.RequireAndVerifyClientCert, + } + return testServer +} + +func webhookHandler(w http.ResponseWriter, r *http.Request) { + fmt.Printf("got req: %v\n", r.URL.Path) + switch r.URL.Path { + case "/internalErr": + http.Error(w, "webhook internal server error", http.StatusInternalServerError) + return + case "/invalidReq": + w.WriteHeader(http.StatusSwitchingProtocols) + w.Write([]byte("webhook invalid request")) + return + case "/invalidResp": + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("webhook invalid response")) + case "/disallow": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ + Response: &v1beta1.AdmissionResponse{ + Allowed: false, + }, + }) + case "/disallowReason": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ + Response: &v1beta1.AdmissionResponse{ + Allowed: false, + Result: &metav1.Status{ + Message: "you shall not pass", + }, + }, + }) + case "/allow": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ + Response: &v1beta1.AdmissionResponse{ + Allowed: true, + }, + }) + case "/nilResponse": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{}) + default: + http.NotFound(w, r) + } +} diff --git a/pkg/admission/plugin/webhook/validating/admission.go b/pkg/admission/plugin/webhook/validating/admission.go deleted file mode 100644 index d3bcb3389..000000000 --- a/pkg/admission/plugin/webhook/validating/admission.go +++ /dev/null @@ -1,305 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 validating delegates admission checks to dynamically configured -// validating webhooks. -package validating - -import ( - "context" - "fmt" - "io" - "sync" - "time" - - "github.com/golang/glog" - - admissionv1beta1 "k8s.io/api/admission/v1beta1" - "k8s.io/api/admissionregistration/v1beta1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/admission/configuration" - genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" - admissionmetrics "k8s.io/apiserver/pkg/admission/metrics" - "k8s.io/apiserver/pkg/admission/plugin/webhook/config" - webhookerrors "k8s.io/apiserver/pkg/admission/plugin/webhook/errors" - "k8s.io/apiserver/pkg/admission/plugin/webhook/namespace" - "k8s.io/apiserver/pkg/admission/plugin/webhook/request" - "k8s.io/apiserver/pkg/admission/plugin/webhook/rules" - "k8s.io/apiserver/pkg/admission/plugin/webhook/versioned" - "k8s.io/client-go/informers" - clientset "k8s.io/client-go/kubernetes" -) - -const ( - // Name of admission plug-in - PluginName = "ValidatingAdmissionWebhook" -) - -// Register registers a plugin -func Register(plugins *admission.Plugins) { - plugins.Register(PluginName, func(configFile io.Reader) (admission.Interface, error) { - plugin, err := NewValidatingAdmissionWebhook(configFile) - if err != nil { - return nil, err - } - - return plugin, nil - }) -} - -// WebhookSource can list dynamic webhook plugins. -type WebhookSource interface { - Webhooks() *v1beta1.ValidatingWebhookConfiguration -} - -// NewValidatingAdmissionWebhook returns a generic admission webhook plugin. -func NewValidatingAdmissionWebhook(configFile io.Reader) (*ValidatingAdmissionWebhook, error) { - kubeconfigFile, err := config.LoadConfig(configFile) - if err != nil { - return nil, err - } - - cm, err := config.NewClientManager() - if err != nil { - return nil, err - } - authInfoResolver, err := config.NewDefaultAuthenticationInfoResolver(kubeconfigFile) - if err != nil { - return nil, err - } - // Set defaults which may be overridden later. - cm.SetAuthenticationInfoResolver(authInfoResolver) - cm.SetServiceResolver(config.NewDefaultServiceResolver()) - - return &ValidatingAdmissionWebhook{ - Handler: admission.NewHandler( - admission.Connect, - admission.Create, - admission.Delete, - admission.Update, - ), - clientManager: cm, - }, nil -} - -var _ admission.ValidationInterface = &ValidatingAdmissionWebhook{} - -// ValidatingAdmissionWebhook is an implementation of admission.Interface. -type ValidatingAdmissionWebhook struct { - *admission.Handler - hookSource WebhookSource - namespaceMatcher namespace.Matcher - clientManager config.ClientManager - convertor versioned.Convertor -} - -var ( - _ = genericadmissioninit.WantsExternalKubeClientSet(&ValidatingAdmissionWebhook{}) -) - -// TODO find a better way wire this, but keep this pull small for now. -func (a *ValidatingAdmissionWebhook) SetAuthenticationInfoResolverWrapper(wrapper config.AuthenticationInfoResolverWrapper) { - a.clientManager.SetAuthenticationInfoResolverWrapper(wrapper) -} - -// SetServiceResolver sets a service resolver for the webhook admission plugin. -// Passing a nil resolver does not have an effect, instead a default one will be used. -func (a *ValidatingAdmissionWebhook) SetServiceResolver(sr config.ServiceResolver) { - a.clientManager.SetServiceResolver(sr) -} - -// SetScheme sets a serializer(NegotiatedSerializer) which is derived from the scheme -func (a *ValidatingAdmissionWebhook) SetScheme(scheme *runtime.Scheme) { - if scheme != nil { - a.convertor.Scheme = scheme - } -} - -// WantsExternalKubeClientSet defines a function which sets external ClientSet for admission plugins that need it -func (a *ValidatingAdmissionWebhook) SetExternalKubeClientSet(client clientset.Interface) { - a.namespaceMatcher.Client = client -} - -// SetExternalKubeInformerFactory implements the WantsExternalKubeInformerFactory interface. -func (a *ValidatingAdmissionWebhook) SetExternalKubeInformerFactory(f informers.SharedInformerFactory) { - namespaceInformer := f.Core().V1().Namespaces() - a.namespaceMatcher.NamespaceLister = namespaceInformer.Lister() - validatingWebhookConfigurationsInformer := f.Admissionregistration().V1beta1().ValidatingWebhookConfigurations() - a.hookSource = configuration.NewValidatingWebhookConfigurationManager(validatingWebhookConfigurationsInformer) - a.SetReadyFunc(func() bool { - return namespaceInformer.Informer().HasSynced() && validatingWebhookConfigurationsInformer.Informer().HasSynced() - }) -} - -// ValidateInitialization implements the InitializationValidator interface. -func (a *ValidatingAdmissionWebhook) ValidateInitialization() error { - if a.hookSource == nil { - return fmt.Errorf("ValidatingAdmissionWebhook admission plugin requires a Kubernetes informer to be provided") - } - if err := a.namespaceMatcher.Validate(); err != nil { - return fmt.Errorf("ValidatingAdmissionWebhook.namespaceMatcher is not properly setup: %v", err) - } - if err := a.clientManager.Validate(); err != nil { - return fmt.Errorf("ValidatingAdmissionWebhook.clientManager is not properly setup: %v", err) - } - if err := a.convertor.Validate(); err != nil { - return fmt.Errorf("ValidatingAdmissionWebhook.convertor is not properly setup: %v", err) - } - return nil -} - -func (a *ValidatingAdmissionWebhook) loadConfiguration(attr admission.Attributes) *v1beta1.ValidatingWebhookConfiguration { - return a.hookSource.Webhooks() -} - -// Validate makes an admission decision based on the request attributes. -func (a *ValidatingAdmissionWebhook) Validate(attr admission.Attributes) error { - if rules.IsWebhookConfigurationResource(attr) { - return nil - } - - if !a.WaitForReady() { - return admission.NewForbidden(attr, fmt.Errorf("not yet ready to handle request")) - } - hookConfig := a.loadConfiguration(attr) - hooks := hookConfig.Webhooks - ctx := context.TODO() - - var relevantHooks []*v1beta1.Webhook - for i := range hooks { - call, err := a.shouldCallHook(&hooks[i], attr) - if err != nil { - return err - } - if call { - relevantHooks = append(relevantHooks, &hooks[i]) - } - } - - if len(relevantHooks) == 0 { - // no matching hooks - return nil - } - - // convert the object to the external version before sending it to the webhook - versionedAttr := versioned.Attributes{ - Attributes: attr, - } - if oldObj := attr.GetOldObject(); oldObj != nil { - out, err := a.convertor.ConvertToGVK(oldObj, attr.GetKind()) - if err != nil { - return apierrors.NewInternalError(err) - } - versionedAttr.OldObject = out - } - if obj := attr.GetObject(); obj != nil { - out, err := a.convertor.ConvertToGVK(obj, attr.GetKind()) - if err != nil { - return apierrors.NewInternalError(err) - } - versionedAttr.Object = out - } - - wg := sync.WaitGroup{} - errCh := make(chan error, len(relevantHooks)) - wg.Add(len(relevantHooks)) - for i := range relevantHooks { - go func(hook *v1beta1.Webhook) { - defer wg.Done() - - t := time.Now() - err := a.callHook(ctx, hook, versionedAttr) - admissionmetrics.Metrics.ObserveWebhook(time.Since(t), err != nil, attr, "validating", hook.Name) - if err == nil { - return - } - - ignoreClientCallFailures := hook.FailurePolicy != nil && *hook.FailurePolicy == v1beta1.Ignore - if callErr, ok := err.(*webhookerrors.ErrCallingWebhook); ok { - if ignoreClientCallFailures { - glog.Warningf("Failed calling webhook, failing open %v: %v", hook.Name, callErr) - utilruntime.HandleError(callErr) - return - } - - glog.Warningf("Failed calling webhook, failing closed %v: %v", hook.Name, err) - errCh <- apierrors.NewInternalError(err) - return - } - - glog.Warningf("rejected by webhook %q: %#v", hook.Name, err) - errCh <- err - }(relevantHooks[i]) - } - wg.Wait() - close(errCh) - - var errs []error - for e := range errCh { - errs = append(errs, e) - } - if len(errs) == 0 { - return nil - } - if len(errs) > 1 { - for i := 1; i < len(errs); i++ { - // TODO: merge status errors; until then, just return the first one. - utilruntime.HandleError(errs[i]) - } - } - return errs[0] -} - -// TODO: factor into a common place along with the mutating webhook version. -func (a *ValidatingAdmissionWebhook) shouldCallHook(h *v1beta1.Webhook, attr admission.Attributes) (bool, *apierrors.StatusError) { - var matches bool - for _, r := range h.Rules { - m := rules.Matcher{Rule: r, Attr: attr} - if m.Matches() { - matches = true - break - } - } - if !matches { - return false, nil - } - - return a.namespaceMatcher.MatchNamespaceSelector(h, attr) -} - -func (a *ValidatingAdmissionWebhook) callHook(ctx context.Context, h *v1beta1.Webhook, attr admission.Attributes) error { - // Make the webhook request - request := request.CreateAdmissionReview(attr) - client, err := a.clientManager.HookClient(h) - if err != nil { - return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} - } - response := &admissionv1beta1.AdmissionReview{} - if err := client.Post().Context(ctx).Body(&request).Do().Into(response); err != nil { - return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} - } - - if response.Response == nil { - return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: fmt.Errorf("Webhook response was absent")} - } - if response.Response.Allowed { - return nil - } - return webhookerrors.ToStatusErr(h.Name, response.Response.Result) -} diff --git a/pkg/admission/plugin/webhook/validating/admission_test.go b/pkg/admission/plugin/webhook/validating/admission_test.go deleted file mode 100644 index 77871f019..000000000 --- a/pkg/admission/plugin/webhook/validating/admission_test.go +++ /dev/null @@ -1,678 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 validating - -import ( - "crypto/tls" - "crypto/x509" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "sync/atomic" - "testing" - - "k8s.io/api/admission/v1beta1" - registrationv1beta1 "k8s.io/api/admissionregistration/v1beta1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/admission" - "k8s.io/apiserver/pkg/admission/plugin/webhook/config" - "k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/client-go/rest" -) - -type fakeHookSource struct { - hooks []registrationv1beta1.Webhook - err error -} - -func (f *fakeHookSource) Webhooks() *registrationv1beta1.ValidatingWebhookConfiguration { - if f.err != nil { - return nil - } - for i, h := range f.hooks { - if h.NamespaceSelector == nil { - f.hooks[i].NamespaceSelector = &metav1.LabelSelector{} - } - } - return ®istrationv1beta1.ValidatingWebhookConfiguration{Webhooks: f.hooks} -} - -func (f *fakeHookSource) Run(stopCh <-chan struct{}) {} - -type fakeServiceResolver struct { - base url.URL -} - -func (f fakeServiceResolver) ResolveEndpoint(namespace, name string) (*url.URL, error) { - if namespace == "failResolve" { - return nil, fmt.Errorf("couldn't resolve service location") - } - u := f.base - return &u, nil -} - -type fakeNamespaceLister struct { - namespaces map[string]*corev1.Namespace -} - -func (f fakeNamespaceLister) List(selector labels.Selector) (ret []*corev1.Namespace, err error) { - return nil, nil -} -func (f fakeNamespaceLister) Get(name string) (*corev1.Namespace, error) { - ns, ok := f.namespaces[name] - if ok { - return ns, nil - } - return nil, errors.NewNotFound(corev1.Resource("namespaces"), name) -} - -// ccfgSVC returns a client config using the service reference mechanism. -func ccfgSVC(urlPath string) registrationv1beta1.WebhookClientConfig { - return registrationv1beta1.WebhookClientConfig{ - Service: ®istrationv1beta1.ServiceReference{ - Name: "webhook-test", - Namespace: "default", - Path: &urlPath, - }, - CABundle: testcerts.CACert, - } -} - -type urlConfigGenerator struct { - baseURL *url.URL -} - -// ccfgURL returns a client config using the URL mechanism. -func (c urlConfigGenerator) ccfgURL(urlPath string) registrationv1beta1.WebhookClientConfig { - u2 := *c.baseURL - u2.Path = urlPath - urlString := u2.String() - return registrationv1beta1.WebhookClientConfig{ - URL: &urlString, - CABundle: testcerts.CACert, - } -} - -// TestValidate tests that ValidatingAdmissionWebhook#Validate works as expected -func TestValidate(t *testing.T) { - scheme := runtime.NewScheme() - v1beta1.AddToScheme(scheme) - corev1.AddToScheme(scheme) - - testServer := newTestServer(t) - testServer.StartTLS() - defer testServer.Close() - serverURL, err := url.ParseRequestURI(testServer.URL) - if err != nil { - t.Fatalf("this should never happen? %v", err) - } - wh, err := NewValidatingAdmissionWebhook(nil) - if err != nil { - t.Fatal(err) - } - cm, err := config.NewClientManager() - if err != nil { - t.Fatalf("cannot create client manager: %v", err) - } - cm.SetAuthenticationInfoResolver(newFakeAuthenticationInfoResolver(new(int32))) - cm.SetServiceResolver(fakeServiceResolver{base: *serverURL}) - wh.clientManager = cm - wh.SetScheme(scheme) - if err = wh.clientManager.Validate(); err != nil { - t.Fatal(err) - } - namespace := "webhook-test" - wh.namespaceMatcher.NamespaceLister = fakeNamespaceLister{map[string]*corev1.Namespace{ - namespace: { - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "runlevel": "0", - }, - }, - }, - }, - } - - // Set up a test object for the call - kind := corev1.SchemeGroupVersion.WithKind("Pod") - name := "my-pod" - object := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "pod.name": name, - }, - Name: name, - Namespace: namespace, - }, - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Pod", - }, - } - oldObject := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - } - operation := admission.Update - resource := corev1.Resource("pods").WithVersion("v1") - subResource := "" - userInfo := user.DefaultInfo{ - Name: "webhook-test", - UID: "webhook-test", - } - - ccfgURL := urlConfigGenerator{serverURL}.ccfgURL - - type test struct { - hookSource fakeHookSource - path string - expectAllow bool - errorContains string - } - - matchEverythingRules := []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, - Rule: registrationv1beta1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }, - }} - - policyFail := registrationv1beta1.Fail - policyIgnore := registrationv1beta1.Ignore - - table := map[string]test{ - "no match": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "nomatch", - ClientConfig: ccfgSVC("disallow"), - Rules: []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.Create}, - }}, - }}, - }, - expectAllow: true, - }, - "match & allow": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "allow", - ClientConfig: ccfgSVC("allow"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: true, - }, - "match & disallow": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "without explanation", - }, - "match & disallow ii": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallowReason", - ClientConfig: ccfgSVC("disallowReason"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "you shall not pass", - }, - "match & disallow & but allowed because namespaceSelector exempt the namespace": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: newMatchEverythingRules(), - NamespaceSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "runlevel", - Values: []string{"1"}, - Operator: metav1.LabelSelectorOpIn, - }}, - }, - }}, - }, - expectAllow: true, - }, - "match & disallow & but allowed because namespaceSelector exempt the namespace ii": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgSVC("disallow"), - Rules: newMatchEverythingRules(), - NamespaceSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "runlevel", - Values: []string{"0"}, - Operator: metav1.LabelSelectorOpNotIn, - }}, - }, - }}, - }, - expectAllow: true, - }, - "match & fail (but allow because fail open)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - }, - "match & fail (but disallow because fail closed on nil)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: false, - }, - "match & fail (but fail because fail closed)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "internalErr A", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }, { - Name: "internalErr B", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }, { - Name: "internalErr C", - ClientConfig: ccfgSVC("internalErr"), - Rules: matchEverythingRules, - FailurePolicy: &policyFail, - }}, - }, - expectAllow: false, - }, - "match & allow (url)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "allow", - ClientConfig: ccfgURL("allow"), - Rules: matchEverythingRules, - }}, - }, - expectAllow: true, - }, - "match & disallow (url)": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "disallow", - ClientConfig: ccfgURL("disallow"), - Rules: matchEverythingRules, - }}, - }, - errorContains: "without explanation", - }, - "absent response and fail open": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "nilResponse", - ClientConfig: ccfgURL("nilResponse"), - FailurePolicy: &policyIgnore, - Rules: matchEverythingRules, - }}, - }, - expectAllow: true, - }, - "absent response and fail closed": { - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "nilResponse", - ClientConfig: ccfgURL("nilResponse"), - FailurePolicy: &policyFail, - Rules: matchEverythingRules, - }}, - }, - errorContains: "Webhook response was absent", - }, - // No need to test everything with the url case, since only the - // connection is different. - } - - for name, tt := range table { - if !strings.Contains(name, "no match") { - continue - } - t.Run(name, func(t *testing.T) { - wh.hookSource = &tt.hookSource - err = wh.Validate(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, name, resource, subResource, operation, &userInfo)) - if tt.expectAllow != (err == nil) { - t.Errorf("expected allowed=%v, but got err=%v", tt.expectAllow, err) - } - // ErrWebhookRejected is not an error for our purposes - if tt.errorContains != "" { - if err == nil || !strings.Contains(err.Error(), tt.errorContains) { - t.Errorf(" expected an error saying %q, but got %v", tt.errorContains, err) - } - } - if _, isStatusErr := err.(*errors.StatusError); err != nil && !isStatusErr { - t.Errorf("%s: expected a StatusError, got %T", name, err) - } - }) - } -} - -// TestValidateCachedClient tests that ValidatingAdmissionWebhook#Validate should cache restClient -func TestValidateCachedClient(t *testing.T) { - scheme := runtime.NewScheme() - v1beta1.AddToScheme(scheme) - corev1.AddToScheme(scheme) - - testServer := newTestServer(t) - testServer.StartTLS() - defer testServer.Close() - serverURL, err := url.ParseRequestURI(testServer.URL) - if err != nil { - t.Fatalf("this should never happen? %v", err) - } - wh, err := NewValidatingAdmissionWebhook(nil) - if err != nil { - t.Fatal(err) - } - cm, err := config.NewClientManager() - if err != nil { - t.Fatalf("cannot create client manager: %v", err) - } - cm.SetServiceResolver(fakeServiceResolver{base: *serverURL}) - wh.clientManager = cm - wh.SetScheme(scheme) - namespace := "webhook-test" - wh.namespaceMatcher.NamespaceLister = fakeNamespaceLister{map[string]*corev1.Namespace{ - namespace: { - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "runlevel": "0", - }, - }, - }, - }, - } - - // Set up a test object for the call - kind := corev1.SchemeGroupVersion.WithKind("Pod") - name := "my-pod" - object := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "pod.name": name, - }, - Name: name, - Namespace: namespace, - }, - TypeMeta: metav1.TypeMeta{ - APIVersion: "v1", - Kind: "Pod", - }, - } - oldObject := corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, - } - operation := admission.Update - resource := corev1.Resource("pods").WithVersion("v1") - subResource := "" - userInfo := user.DefaultInfo{ - Name: "webhook-test", - UID: "webhook-test", - } - ccfgURL := urlConfigGenerator{serverURL}.ccfgURL - - type test struct { - name string - hookSource fakeHookSource - expectAllow bool - expectCache bool - } - - policyIgnore := registrationv1beta1.Ignore - cases := []test{ - { - name: "cache 1", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache1", - ClientConfig: ccfgSVC("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 2", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache2", - ClientConfig: ccfgSVC("internalErr"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 3", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache3", - ClientConfig: ccfgSVC("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: false, - }, - { - name: "cache 4", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache4", - ClientConfig: ccfgURL("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: true, - }, - { - name: "cache 5", - hookSource: fakeHookSource{ - hooks: []registrationv1beta1.Webhook{{ - Name: "cache5", - ClientConfig: ccfgURL("allow"), - Rules: newMatchEverythingRules(), - FailurePolicy: &policyIgnore, - }}, - }, - expectAllow: true, - expectCache: false, - }, - } - - for _, testcase := range cases { - t.Run(testcase.name, func(t *testing.T) { - wh.hookSource = &testcase.hookSource - authInfoResolverCount := new(int32) - r := newFakeAuthenticationInfoResolver(authInfoResolverCount) - wh.clientManager.SetAuthenticationInfoResolver(r) - if err = wh.clientManager.Validate(); err != nil { - t.Fatal(err) - } - - err = wh.Validate(admission.NewAttributesRecord(&object, &oldObject, kind, namespace, testcase.name, resource, subResource, operation, &userInfo)) - if testcase.expectAllow != (err == nil) { - t.Errorf("expected allowed=%v, but got err=%v", testcase.expectAllow, err) - } - - if testcase.expectCache && *authInfoResolverCount != 1 { - t.Errorf("expected cacheclient, but got none") - } - - if !testcase.expectCache && *authInfoResolverCount != 0 { - t.Errorf("expected not cacheclient, but got cache") - } - }) - } - -} - -func newTestServer(t *testing.T) *httptest.Server { - // Create the test webhook server - sCert, err := tls.X509KeyPair(testcerts.ServerCert, testcerts.ServerKey) - if err != nil { - t.Fatal(err) - } - rootCAs := x509.NewCertPool() - rootCAs.AppendCertsFromPEM(testcerts.CACert) - testServer := httptest.NewUnstartedServer(http.HandlerFunc(webhookHandler)) - testServer.TLS = &tls.Config{ - Certificates: []tls.Certificate{sCert}, - ClientCAs: rootCAs, - ClientAuth: tls.RequireAndVerifyClientCert, - } - return testServer -} - -func webhookHandler(w http.ResponseWriter, r *http.Request) { - fmt.Printf("got req: %v\n", r.URL.Path) - switch r.URL.Path { - case "/internalErr": - http.Error(w, "webhook internal server error", http.StatusInternalServerError) - return - case "/invalidReq": - w.WriteHeader(http.StatusSwitchingProtocols) - w.Write([]byte("webhook invalid request")) - return - case "/invalidResp": - w.Header().Set("Content-Type", "application/json") - w.Write([]byte("webhook invalid response")) - case "/disallow": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: false, - }, - }) - case "/disallowReason": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: false, - Result: &metav1.Status{ - Message: "you shall not pass", - }, - }, - }) - case "/allow": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{ - Response: &v1beta1.AdmissionResponse{ - Allowed: true, - }, - }) - case "/nilResposne": - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(&v1beta1.AdmissionReview{}) - default: - http.NotFound(w, r) - } -} - -func newFakeAuthenticationInfoResolver(count *int32) *fakeAuthenticationInfoResolver { - return &fakeAuthenticationInfoResolver{ - restConfig: &rest.Config{ - TLSClientConfig: rest.TLSClientConfig{ - CAData: testcerts.CACert, - CertData: testcerts.ClientCert, - KeyData: testcerts.ClientKey, - }, - }, - cachedCount: count, - } -} - -type fakeAuthenticationInfoResolver struct { - restConfig *rest.Config - cachedCount *int32 -} - -func (c *fakeAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) { - atomic.AddInt32(c.cachedCount, 1) - return c.restConfig, nil -} - -func (c *fakeAuthenticationInfoResolver) ClientConfigForService(serviceName, serviceNamespace string) (*rest.Config, error) { - atomic.AddInt32(c.cachedCount, 1) - return c.restConfig, nil -} - -func newMatchEverythingRules() []registrationv1beta1.RuleWithOperations { - return []registrationv1beta1.RuleWithOperations{{ - Operations: []registrationv1beta1.OperationType{registrationv1beta1.OperationAll}, - Rule: registrationv1beta1.Rule{ - APIGroups: []string{"*"}, - APIVersions: []string{"*"}, - Resources: []string{"*/*"}, - }, - }} -} diff --git a/pkg/admission/plugin/webhook/validating/dispatcher.go b/pkg/admission/plugin/webhook/validating/dispatcher.go new file mode 100644 index 000000000..528d79a87 --- /dev/null +++ b/pkg/admission/plugin/webhook/validating/dispatcher.go @@ -0,0 +1,118 @@ +/* +Copyright 2018 The Kubernetes Authors. + +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 validating + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/golang/glog" + "k8s.io/apiserver/pkg/admission/plugin/webhook/config" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + + admissionv1beta1 "k8s.io/api/admission/v1beta1" + "k8s.io/api/admissionregistration/v1beta1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + admissionmetrics "k8s.io/apiserver/pkg/admission/metrics" + webhookerrors "k8s.io/apiserver/pkg/admission/plugin/webhook/errors" + "k8s.io/apiserver/pkg/admission/plugin/webhook/request" +) + +type validatingDispatcher struct { + cm *config.ClientManager +} + +func newValidatingDispatcher(cm *config.ClientManager) generic.Dispatcher { + return &validatingDispatcher{cm} +} + +var _ generic.Dispatcher = &validatingDispatcher{} + +func (d *validatingDispatcher) Dispatch(ctx context.Context, attr *generic.VersionedAttributes, relevantHooks []*v1beta1.Webhook) error { + wg := sync.WaitGroup{} + errCh := make(chan error, len(relevantHooks)) + wg.Add(len(relevantHooks)) + for i := range relevantHooks { + go func(hook *v1beta1.Webhook) { + defer wg.Done() + + t := time.Now() + err := d.callHook(ctx, hook, attr) + admissionmetrics.Metrics.ObserveWebhook(time.Since(t), err != nil, attr.Attributes, "validating", hook.Name) + if err == nil { + return + } + + ignoreClientCallFailures := hook.FailurePolicy != nil && *hook.FailurePolicy == v1beta1.Ignore + if callErr, ok := err.(*webhookerrors.ErrCallingWebhook); ok { + if ignoreClientCallFailures { + glog.Warningf("Failed calling webhook, failing open %v: %v", hook.Name, callErr) + utilruntime.HandleError(callErr) + return + } + + glog.Warningf("Failed calling webhook, failing closed %v: %v", hook.Name, err) + errCh <- apierrors.NewInternalError(err) + return + } + + glog.Warningf("rejected by webhook %q: %#v", hook.Name, err) + errCh <- err + }(relevantHooks[i]) + } + wg.Wait() + close(errCh) + + var errs []error + for e := range errCh { + errs = append(errs, e) + } + if len(errs) == 0 { + return nil + } + if len(errs) > 1 { + for i := 1; i < len(errs); i++ { + // TODO: merge status errors; until then, just return the first one. + utilruntime.HandleError(errs[i]) + } + } + return errs[0] +} + +func (d *validatingDispatcher) callHook(ctx context.Context, h *v1beta1.Webhook, attr *generic.VersionedAttributes) error { + // Make the webhook request + request := request.CreateAdmissionReview(attr) + client, err := d.cm.HookClient(h) + if err != nil { + return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} + } + response := &admissionv1beta1.AdmissionReview{} + if err := client.Post().Context(ctx).Body(&request).Do().Into(response); err != nil { + return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: err} + } + + if response.Response == nil { + return &webhookerrors.ErrCallingWebhook{WebhookName: h.Name, Reason: fmt.Errorf("Webhook response was absent")} + } + if response.Response.Allowed { + return nil + } + return webhookerrors.ToStatusErr(h.Name, response.Response.Result) +} diff --git a/pkg/admission/plugin/webhook/validating/plugin.go b/pkg/admission/plugin/webhook/validating/plugin.go new file mode 100644 index 000000000..8417ccffb --- /dev/null +++ b/pkg/admission/plugin/webhook/validating/plugin.go @@ -0,0 +1,64 @@ +/* +Copyright 2017 The Kubernetes Authors. + +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 validating + +import ( + "io" + + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/configuration" + "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" +) + +const ( + // Name of admission plug-in + PluginName = "ValidatingAdmissionWebhook" +) + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(configFile io.Reader) (admission.Interface, error) { + plugin, err := NewValidatingAdmissionWebhook(configFile) + if err != nil { + return nil, err + } + + return plugin, nil + }) +} + +// Plugin is an implementation of admission.Interface. +type Plugin struct { + *generic.Webhook +} + +var _ admission.ValidationInterface = &Plugin{} + +// NewValidatingAdmissionWebhook returns a generic admission webhook plugin. +func NewValidatingAdmissionWebhook(configFile io.Reader) (*Plugin, error) { + handler := admission.NewHandler(admission.Connect, admission.Create, admission.Delete, admission.Update) + webhook, err := generic.NewWebhook(handler, configFile, configuration.NewValidatingWebhookConfigurationManager, newValidatingDispatcher) + if err != nil { + return nil, err + } + return &Plugin{webhook}, nil +} + +// Validate makes an admission decision based on the request attributes. +func (a *Plugin) Validate(attr admission.Attributes) error { + return a.Webhook.Dispatch(attr) +} diff --git a/pkg/admission/plugin/webhook/validating/plugin_test.go b/pkg/admission/plugin/webhook/validating/plugin_test.go new file mode 100644 index 000000000..65832d0a2 --- /dev/null +++ b/pkg/admission/plugin/webhook/validating/plugin_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2017 The Kubernetes Authors. + +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 validating + +import ( + "net/url" + "strings" + "testing" + + "k8s.io/api/admission/v1beta1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + webhooktesting "k8s.io/apiserver/pkg/admission/plugin/webhook/testing" +) + +// TestValidate tests that ValidatingWebhook#Validate works as expected +func TestValidate(t *testing.T) { + scheme := runtime.NewScheme() + v1beta1.AddToScheme(scheme) + corev1.AddToScheme(scheme) + + testServer := webhooktesting.NewTestServer(t) + testServer.StartTLS() + defer testServer.Close() + + serverURL, err := url.ParseRequestURI(testServer.URL) + if err != nil { + t.Fatalf("this should never happen? %v", err) + } + + stopCh := make(chan struct{}) + defer close(stopCh) + + for _, tt := range webhooktesting.NewTestCases(serverURL) { + // TODO: re-enable all tests + if !strings.Contains(tt.Name, "no match") { + continue + } + + wh, err := NewValidatingAdmissionWebhook(nil) + if err != nil { + t.Errorf("%s: failed to create validating webhook: %v", tt.Name, err) + continue + } + + ns := "webhook-test" + client, informer := webhooktesting.NewFakeDataSource(ns, tt.Webhooks, false, stopCh) + + wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(new(int32)))) + wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL)) + wh.SetScheme(scheme) + wh.SetExternalKubeClientSet(client) + wh.SetExternalKubeInformerFactory(informer) + + informer.Start(stopCh) + informer.WaitForCacheSync(stopCh) + + if err = wh.ValidateInitialization(); err != nil { + t.Errorf("%s: failed to validate initialization: %v", tt.Name, err) + continue + } + + err = wh.Validate(webhooktesting.NewAttribute(ns)) + if tt.ExpectAllow != (err == nil) { + t.Errorf("%s: expected allowed=%v, but got err=%v", tt.Name, tt.ExpectAllow, err) + } + // ErrWebhookRejected is not an error for our purposes + if tt.ErrorContains != "" { + if err == nil || !strings.Contains(err.Error(), tt.ErrorContains) { + t.Errorf("%s: expected an error saying %q, but got %v", tt.Name, tt.ErrorContains, err) + } + } + if _, isStatusErr := err.(*errors.StatusError); err != nil && !isStatusErr { + t.Errorf("%s: expected a StatusError, got %T", tt.Name, err) + } + } +} + +// TestValidateCachedClient tests that ValidatingWebhook#Validate should cache restClient +func TestValidateCachedClient(t *testing.T) { + scheme := runtime.NewScheme() + v1beta1.AddToScheme(scheme) + corev1.AddToScheme(scheme) + + testServer := webhooktesting.NewTestServer(t) + testServer.StartTLS() + defer testServer.Close() + serverURL, err := url.ParseRequestURI(testServer.URL) + if err != nil { + t.Fatalf("this should never happen? %v", err) + } + + stopCh := make(chan struct{}) + defer close(stopCh) + + wh, err := NewValidatingAdmissionWebhook(nil) + if err != nil { + t.Fatalf("Failed to create validating webhook: %v", err) + } + wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL)) + wh.SetScheme(scheme) + + for _, tt := range webhooktesting.NewCachedClientTestcases(serverURL) { + ns := "webhook-test" + client, informer := webhooktesting.NewFakeDataSource(ns, tt.Webhooks, false, stopCh) + + // override the webhook source. The client cache will stay the same. + cacheMisses := new(int32) + wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(cacheMisses))) + wh.SetExternalKubeClientSet(client) + wh.SetExternalKubeInformerFactory(informer) + + informer.Start(stopCh) + informer.WaitForCacheSync(stopCh) + + if err = wh.ValidateInitialization(); err != nil { + t.Errorf("%s: failed to validate initialization: %v", tt.Name, err) + continue + } + + err = wh.Validate(webhooktesting.NewAttribute(ns)) + if tt.ExpectAllow != (err == nil) { + t.Errorf("%s: expected allowed=%v, but got err=%v", tt.Name, tt.ExpectAllow, err) + } + + if tt.ExpectCacheMiss && *cacheMisses == 0 { + t.Errorf("%s: expected cache miss, but got no AuthenticationInfoResolver call", tt.Name) + } + + if !tt.ExpectCacheMiss && *cacheMisses > 0 { + t.Errorf("%s: expected client to be cached, but got %d AuthenticationInfoResolver calls", tt.Name, *cacheMisses) + } + } +} diff --git a/pkg/admission/plugin/webhook/versioned/attributes.go b/pkg/admission/plugin/webhook/versioned/attributes.go deleted file mode 100644 index 58f8ae6aa..000000000 --- a/pkg/admission/plugin/webhook/versioned/attributes.go +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 versioned - -import ( - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apiserver/pkg/admission" -) - -// Attributes is a wrapper around the original admission attributes. It allows -// override the internal objects with the versioned ones. -type Attributes struct { - admission.Attributes - OldObject runtime.Object - Object runtime.Object -} - -// GetObject overrides the original GetObjects() and it returns the versioned -// object. -func (v Attributes) GetObject() runtime.Object { - return v.Object -} - -// GetOldObject overrides the original GetOldObjects() and it returns the -// versioned oldObject. -func (v Attributes) GetOldObject() runtime.Object { - return v.OldObject -} diff --git a/pkg/admission/plugin/webhook/versioned/doc.go b/pkg/admission/plugin/webhook/versioned/doc.go deleted file mode 100644 index d557a9fec..000000000 --- a/pkg/admission/plugin/webhook/versioned/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -/* -Copyright 2017 The Kubernetes Authors. - -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 versioned provides tools for making sure the objects sent to a -// webhook are in a version the webhook understands. -package versioned // import "k8s.io/apiserver/pkg/admission/plugin/webhook/versioned" diff --git a/pkg/admission/plugins.go b/pkg/admission/plugins.go index 90d6d5f96..c17d62cd4 100644 --- a/pkg/admission/plugins.go +++ b/pkg/admission/plugins.go @@ -172,16 +172,16 @@ func (ps *Plugins) InitPlugin(name string, config io.Reader, pluginInitializer P plugin, found, err := ps.getPlugin(name, config) if err != nil { - return nil, fmt.Errorf("Couldn't init admission plugin %q: %v", name, err) + return nil, fmt.Errorf("couldn't init admission plugin %q: %v", name, err) } if !found { - return nil, fmt.Errorf("Unknown admission plugin: %s", name) + return nil, fmt.Errorf("unknown admission plugin: %s", name) } pluginInitializer.Initialize(plugin) // ensure that plugins have been properly initialized if err := ValidateInitialization(plugin); err != nil { - return nil, err + return nil, fmt.Errorf("failed to initialize admission plugin %q: %v", name, err) } return plugin, nil