diff --git a/pkg/apis/admissionregistration/types.go b/pkg/apis/admissionregistration/types.go index f92f6f89e2..5e81ce43c9 100644 --- a/pkg/apis/admissionregistration/types.go +++ b/pkg/apis/admissionregistration/types.go @@ -191,6 +191,52 @@ type Webhook struct { // allowed values are Ignore or Fail. Defaults to Ignore. // +optional FailurePolicy *FailurePolicyType + + // NamespaceSelector decides whether to run the webhook on an object based + // on whether the namespace for that object matches the selector. If the + // object itself is a namespace, the matching is performed on + // object.metadata.labels. If the object is other cluster scoped resource, + // it is not subjected to the webhook. + // + // For example, to run the webhook on any objects whose namespace is not + // associated with "runlevel" of "0" or "1"; you will set the selector as + // follows: + // "namespaceSelector": { + // "matchExpressions": [ + // { + // "key": "runlevel", + // "operator": "NotIn", + // "values": [ + // "0", + // "1" + // ] + // } + // ] + // } + // + // If instead you want to only run the webhook on any objects whose + // namespace is associated with the "environment" of "prod" or "staging"; + // you will set the selector as follows: + // "namespaceSelector": { + // "matchExpressions": [ + // { + // "key": "environment", + // "operator": "In", + // "values": [ + // "prod", + // "staging" + // ] + // } + // ] + // } + // + // See + // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + // for more examples of label selectors. + // + // Default to the empty LabelSelector, which matches everything. + // +optional + NamespaceSelector *metav1.LabelSelector } // RuleWithOperations is a tuple of Operations and Resources. It is recommended to make diff --git a/pkg/apis/admissionregistration/v1alpha1/defaults.go b/pkg/apis/admissionregistration/v1alpha1/defaults.go index 92fe685c76..3170c28727 100644 --- a/pkg/apis/admissionregistration/v1alpha1/defaults.go +++ b/pkg/apis/admissionregistration/v1alpha1/defaults.go @@ -18,6 +18,7 @@ package v1alpha1 import ( admissionregistrationv1alpha1 "k8s.io/api/admissionregistration/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -30,4 +31,8 @@ func SetDefaults_Webhook(obj *admissionregistrationv1alpha1.Webhook) { policy := admissionregistrationv1alpha1.Ignore obj.FailurePolicy = &policy } + if obj.NamespaceSelector == nil { + selector := metav1.LabelSelector{} + obj.NamespaceSelector = &selector + } } diff --git a/pkg/apis/admissionregistration/validation/validation.go b/pkg/apis/admissionregistration/validation/validation.go index e907487f33..11df7ac4f5 100644 --- a/pkg/apis/admissionregistration/validation/validation.go +++ b/pkg/apis/admissionregistration/validation/validation.go @@ -21,6 +21,7 @@ import ( "strings" genericvalidation "k8s.io/apimachinery/pkg/api/validation" + metav1validation "k8s.io/apimachinery/pkg/apis/meta/v1/validation" "k8s.io/apimachinery/pkg/util/sets" "k8s.io/apimachinery/pkg/util/validation" "k8s.io/apimachinery/pkg/util/validation/field" @@ -195,6 +196,10 @@ func validateWebhook(hook *admissionregistration.Webhook, fldPath *field.Path) f allErrors = append(allErrors, validateURLPath(fldPath.Child("clientConfig", "urlPath"), hook.ClientConfig.URLPath)...) } + if hook.NamespaceSelector != nil { + allErrors = append(allErrors, metav1validation.ValidateLabelSelector(hook.NamespaceSelector, fldPath.Child("namespaceSelector"))...) + } + return allErrors } diff --git a/staging/src/k8s.io/api/admissionregistration/v1alpha1/types.go b/staging/src/k8s.io/api/admissionregistration/v1alpha1/types.go index 9ac0576bfd..9232c1eb34 100644 --- a/staging/src/k8s.io/api/admissionregistration/v1alpha1/types.go +++ b/staging/src/k8s.io/api/admissionregistration/v1alpha1/types.go @@ -197,6 +197,52 @@ type Webhook struct { // allowed values are Ignore or Fail. Defaults to Ignore. // +optional FailurePolicy *FailurePolicyType `json:"failurePolicy,omitempty" protobuf:"bytes,4,opt,name=failurePolicy,casttype=FailurePolicyType"` + + // NamespaceSelector decides whether to run the webhook on an object based + // on whether the namespace for that object matches the selector. If the + // object itself is a namespace, the matching is performed on + // object.metadata.labels. If the object is other cluster scoped resource, + // it is not subjected to the webhook. + // + // For example, to run the webhook on any objects whose namespace is not + // associated with "runlevel" of "0" or "1"; you will set the selector as + // follows: + // "namespaceSelector": { + // "matchExpressions": [ + // { + // "key": "runlevel", + // "operator": "NotIn", + // "values": [ + // "0", + // "1" + // ] + // } + // ] + // } + // + // If instead you want to only run the webhook on any objects whose + // namespace is associated with the "environment" of "prod" or "staging"; + // you will set the selector as follows: + // "namespaceSelector": { + // "matchExpressions": [ + // { + // "key": "environment", + // "operator": "In", + // "values": [ + // "prod", + // "staging" + // ] + // } + // ] + // } + // + // See + // https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ + // for more examples of label selectors. + // + // Default to the empty LabelSelector, which matches everything. + // +optional + NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty" protobuf:"bytes,5,opt,name=namespaceSelector"` } // RuleWithOperations is a tuple of Operations and Resources. It is recommended to make diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission.go index af4c3a9c24..96f5b1ea34 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission.go @@ -32,7 +32,9 @@ import ( admissionv1alpha1 "k8s.io/api/admission/v1alpha1" "k8s.io/api/admissionregistration/v1alpha1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -41,7 +43,9 @@ import ( "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/configuration" genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer" + "k8s.io/client-go/informers" clientset "k8s.io/client-go/kubernetes" + corelisters "k8s.io/client-go/listers/core/v1" "k8s.io/client-go/rest" ) @@ -123,6 +127,8 @@ type GenericAdmissionWebhook struct { hookSource WebhookSource serviceResolver ServiceResolver negotiatedSerializer runtime.NegotiatedSerializer + namespaceLister corelisters.NamespaceLister + client clientset.Interface authInfoResolver AuthenticationInfoResolver cache *lru.Cache @@ -163,9 +169,17 @@ func (a *GenericAdmissionWebhook) SetScheme(scheme *runtime.Scheme) { // WantsExternalKubeClientSet defines a function which sets external ClientSet for admission plugins that need it func (a *GenericAdmissionWebhook) SetExternalKubeClientSet(client clientset.Interface) { + a.client = client a.hookSource = configuration.NewValidatingWebhookConfigurationManager(client.AdmissionregistrationV1alpha1().ValidatingWebhookConfigurations()) } +// SetExternalKubeInformerFactory implements the WantsExternalKubeInformerFactory interface. +func (a *GenericAdmissionWebhook) SetExternalKubeInformerFactory(f informers.SharedInformerFactory) { + namespaceInformer := f.Core().V1().Namespaces() + a.namespaceLister = namespaceInformer.Lister() + a.SetReadyFunc(namespaceInformer.Informer().HasSynced) +} + // ValidateInitialization implements the InitializationValidator interface. func (a *GenericAdmissionWebhook) ValidateInitialization() error { if a.hookSource == nil { @@ -174,6 +188,9 @@ func (a *GenericAdmissionWebhook) ValidateInitialization() error { if a.negotiatedSerializer == nil { return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a runtime.Scheme to be provided to derive a serializer") } + if a.namespaceLister == nil { + return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a namespaceLister") + } go a.hookSource.Run(wait.NeverStop) return nil } @@ -255,7 +272,74 @@ func (a *GenericAdmissionWebhook) Admit(attr admission.Attributes) error { return errs[0] } +func (a *GenericAdmissionWebhook) getNamespaceLabels(attr admission.Attributes) (map[string]string, error) { + // If the request itself is creating or updating a namespace, then get the + // labels from attr.Object, because namespaceLister doesn't have the latest + // namespace yet. + // + // However, if the request is deleting a namespace, then get the label from + // the namespace in the namespaceLister, because a delete request is not + // going to change the object, and attr.Object will be a DeleteOptions + // rather than a namespace object. + if attr.GetResource().Resource == "namespaces" && + len(attr.GetSubresource()) == 0 && + (attr.GetOperation() == admission.Create || attr.GetOperation() == admission.Update) { + accessor, err := meta.Accessor(attr.GetObject()) + if err != nil { + return nil, err + } + return accessor.GetLabels(), nil + } + + namespaceName := attr.GetNamespace() + namespace, err := a.namespaceLister.Get(namespaceName) + if err != nil && !apierrors.IsNotFound(err) { + return nil, err + } + if apierrors.IsNotFound(err) { + // in case of latency in our caches, make a call direct to storage to verify that it truly exists or not + namespace, err = a.client.Core().Namespaces().Get(namespaceName, metav1.GetOptions{}) + if err != nil { + return nil, err + } + } + return namespace.Labels, nil +} + +// whether the request is exempted by the webhook because of the +// namespaceSelector of the webhook. +func (a *GenericAdmissionWebhook) exemptedByNamespaceSelector(h *v1alpha1.Webhook, attr admission.Attributes) (bool, error) { + namespaceName := attr.GetNamespace() + if len(namespaceName) == 0 && attr.GetResource().Resource != "namespaces" { + // If the request is about a cluster scoped resource, and it is not a + // namespace, it is exempted from all webhooks for now. + // TODO: figure out a way selective exempt cluster scoped resources. + // Also update the comment in types.go + return true, nil + } + namespaceLabels, err := a.getNamespaceLabels(attr) + if apierrors.IsNotFound(err) { + return false, err + } + if err != nil { + return false, apierrors.NewInternalError(err) + } + // TODO: adding an LRU cache to cache the translation + selector, err := metav1.LabelSelectorAsSelector(h.NamespaceSelector) + if err != nil { + return false, apierrors.NewInternalError(err) + } + return !selector.Matches(labels.Set(namespaceLabels)), nil +} + func (a *GenericAdmissionWebhook) callHook(ctx context.Context, h *v1alpha1.Webhook, attr admission.Attributes) error { + excluded, err := a.exemptedByNamespaceSelector(h, attr) + if err != nil { + return err + } + if excluded { + return nil + } matches := false for _, r := range h.Rules { m := RuleMatcher{Rule: r, Attr: attr} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission_test.go index 39ad0c34f7..25308fa230 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission_test.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/webhook/admission_test.go @@ -24,16 +24,20 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "sync/atomic" "testing" "k8s.io/api/admission/v1alpha1" registrationv1alpha1 "k8s.io/api/admissionregistration/v1alpha1" - api "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" apierrors "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/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/rest" @@ -48,6 +52,11 @@ func (f *fakeHookSource) Webhooks() (*registrationv1alpha1.ValidatingWebhookConf if f.err != nil { return nil, f.err } + for i, h := range f.hooks { + if h.NamespaceSelector == nil { + f.hooks[i].NamespaceSelector = &metav1.LabelSelector{} + } + } return ®istrationv1alpha1.ValidatingWebhookConfiguration{Webhooks: f.hooks}, nil } @@ -65,11 +74,26 @@ func (f fakeServiceResolver) ResolveEndpoint(namespace, name string) (*url.URL, 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) +} + // TestAdmit tests that GenericAdmissionWebhook#Admit works as expected func TestAdmit(t *testing.T) { scheme := runtime.NewScheme() v1alpha1.AddToScheme(scheme) - api.AddToScheme(scheme) + corev1.AddToScheme(scheme) testServer := newTestServer(t) testServer.StartTLS() @@ -85,12 +109,22 @@ func TestAdmit(t *testing.T) { wh.authInfoResolver = newFakeAuthenticationInfoResolver() wh.serviceResolver = fakeServiceResolver{base: *serverURL} wh.SetScheme(scheme) + namespace := "webhook-test" + wh.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 := api.SchemeGroupVersion.WithKind("Pod") + kind := corev1.SchemeGroupVersion.WithKind("Pod") name := "my-pod" - namespace := "webhook-test" - object := api.Pod{ + object := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ "pod.name": name, @@ -103,11 +137,11 @@ func TestAdmit(t *testing.T) { Kind: "Pod", }, } - oldObject := api.Pod{ + oldObject := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, } operation := admission.Update - resource := api.Resource("pods").WithVersion("v1") + resource := corev1.Resource("pods").WithVersion("v1") subResource := "" userInfo := user.DefaultInfo{ Name: "webhook-test", @@ -167,6 +201,40 @@ func TestAdmit(t *testing.T) { }, errorContains: "you shall not pass", }, + "match & disallow & but allowed because namespaceSelector exempt the namespace": { + hookSource: fakeHookSource{ + hooks: []registrationv1alpha1.Webhook{{ + Name: "disallow", + ClientConfig: newFakeHookClientConfig("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: []registrationv1alpha1.Webhook{{ + Name: "disallow", + ClientConfig: newFakeHookClientConfig("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: []registrationv1alpha1.Webhook{{ @@ -230,9 +298,11 @@ func TestAdmit(t *testing.T) { } 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) @@ -254,7 +324,7 @@ func TestAdmit(t *testing.T) { func TestAdmitCachedClient(t *testing.T) { scheme := runtime.NewScheme() v1alpha1.AddToScheme(scheme) - api.AddToScheme(scheme) + corev1.AddToScheme(scheme) testServer := newTestServer(t) testServer.StartTLS() @@ -270,12 +340,22 @@ func TestAdmitCachedClient(t *testing.T) { wh.authInfoResolver = newFakeAuthenticationInfoResolver() wh.serviceResolver = fakeServiceResolver{base: *serverURL} wh.SetScheme(scheme) + namespace := "webhook-test" + wh.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 := api.SchemeGroupVersion.WithKind("Pod") + kind := corev1.SchemeGroupVersion.WithKind("Pod") name := "my-pod" - namespace := "webhook-test" - object := api.Pod{ + object := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ "pod.name": name, @@ -288,11 +368,11 @@ func TestAdmitCachedClient(t *testing.T) { Kind: "Pod", }, } - oldObject := api.Pod{ + oldObject := corev1.Pod{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, } operation := admission.Update - resource := api.Resource("pods").WithVersion("v1") + resource := corev1.Resource("pods").WithVersion("v1") subResource := "" userInfo := user.DefaultInfo{ Name: "webhook-test", @@ -522,3 +602,89 @@ func newMatchEverythingRules() []registrationv1alpha1.RuleWithOperations { }, }} } + +func TestGetNamespaceLabels(t *testing.T) { + namespace1Labels := map[string]string{ + "runlevel": "1", + } + namespace1 := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "1", + Labels: namespace1Labels, + }, + } + namespace2Labels := map[string]string{ + "runlevel": "2", + } + namespace2 := corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "2", + Labels: namespace2Labels, + }, + } + namespaceLister := fakeNamespaceLister{map[string]*corev1.Namespace{ + "1": &namespace1, + }, + } + + tests := []struct { + name string + attr admission.Attributes + expectedLabels map[string]string + }{ + { + name: "request is for creating namespace, the labels should be from the object itself", + attr: admission.NewAttributesRecord(&namespace2, nil, schema.GroupVersionKind{}, "", namespace2.Name, schema.GroupVersionResource{Resource: "namespaces"}, "", admission.Create, nil), + expectedLabels: namespace2Labels, + }, + { + name: "request is for updating namespace, the labels should be from the new object", + attr: admission.NewAttributesRecord(&namespace2, nil, schema.GroupVersionKind{}, namespace2.Name, namespace2.Name, schema.GroupVersionResource{Resource: "namespaces"}, "", admission.Update, nil), + expectedLabels: namespace2Labels, + }, + { + name: "request is for deleting namespace, the labels should be from the cache", + attr: admission.NewAttributesRecord(&namespace2, nil, schema.GroupVersionKind{}, namespace1.Name, namespace1.Name, schema.GroupVersionResource{Resource: "namespaces"}, "", admission.Delete, nil), + expectedLabels: namespace1Labels, + }, + { + name: "request is for namespace/finalizer", + attr: admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{}, namespace1.Name, "mock-name", schema.GroupVersionResource{Resource: "namespaces"}, "finalizers", admission.Create, nil), + expectedLabels: namespace1Labels, + }, + { + name: "request is for pod", + attr: admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{}, namespace1.Name, "mock-name", schema.GroupVersionResource{Resource: "pods"}, "", admission.Create, nil), + expectedLabels: namespace1Labels, + }, + } + wh, err := NewGenericAdmissionWebhook(nil) + if err != nil { + t.Fatal(err) + } + wh.namespaceLister = namespaceLister + for _, tt := range tests { + actualLabels, err := wh.getNamespaceLabels(tt.attr) + if err != nil { + t.Error(err) + } + if !reflect.DeepEqual(actualLabels, tt.expectedLabels) { + t.Errorf("expected labels to be %#v, got %#v", tt.expectedLabels, actualLabels) + } + } +} + +func TestExemptClusterScopedResource(t *testing.T) { + hook := ®istrationv1alpha1.Webhook{ + NamespaceSelector: &metav1.LabelSelector{}, + } + attr := admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{}, "", "mock-name", schema.GroupVersionResource{Version: "v1", Resource: "nodes"}, "", admission.Create, nil) + g := GenericAdmissionWebhook{} + exempted, err := g.exemptedByNamespaceSelector(hook, attr) + if err != nil { + t.Fatal(err) + } + if !exempted { + t.Errorf("cluster scoped resources (but not a namespace) should be exempted from all webhooks") + } +} diff --git a/test/e2e/apimachinery/webhook.go b/test/e2e/apimachinery/webhook.go index 22c0e1af69..08e02b8243 100644 --- a/test/e2e/apimachinery/webhook.go +++ b/test/e2e/apimachinery/webhook.go @@ -27,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" utilversion "k8s.io/kubernetes/pkg/util/version" "k8s.io/kubernetes/test/e2e/framework" @@ -36,11 +37,16 @@ import ( ) const ( - secretName = "sample-webhook-secret" - deploymentName = "sample-webhook-deployment" - serviceName = "e2e-test-webhook" - roleBindingName = "webhook-auth-reader" - webhookConfigName = "e2e-test-webhook-config" + secretName = "sample-webhook-secret" + deploymentName = "sample-webhook-deployment" + serviceName = "e2e-test-webhook" + roleBindingName = "webhook-auth-reader" + webhookConfigName = "e2e-test-webhook-config" + skipNamespaceLabelKey = "skip-webhook-admission" + skipNamespaceLabelValue = "yes" + skippedNamespaceName = "exempted-namesapce" + disallowedPodName = "disallowed-pod" + disallowedConfigMapName = "disallowed-configmap" ) var serverWebhookVersion = utilversion.MustParseSemantic("v1.8.0") @@ -51,7 +57,7 @@ var _ = SIGDescribe("AdmissionWebhook", func() { cleanWebhookTest(f) }) - It("Should be able to deny pod creation", func() { + It("Should be able to deny pod and configmap creation", func() { // Make sure the relevant provider supports admission webhook framework.SkipUnlessServerVersionGTE(serverWebhookVersion, f.ClientSet.Discovery()) framework.SkipUnlessProviderIs("gce", "gke") @@ -68,7 +74,7 @@ var _ = SIGDescribe("AdmissionWebhook", func() { // Note that in 1.9 we will have backwards incompatible change to // admission webhooks, so the image will be updated to 1.9 sometime in // the development 1.9 cycle. - deployWebhookAndService(f, "gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v1", context) + deployWebhookAndService(f, "gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v2", context) registerWebhook(f, context) testWebhook(f) }) @@ -223,7 +229,7 @@ func registerWebhook(f *framework.Framework, context *certContext) { }, Webhooks: []v1alpha1.Webhook{ { - Name: "e2e-test-webhook.k8s.io", + Name: "deny-unwanted-pod-container-name-and-label.k8s.io", Rules: []v1alpha1.RuleWithOperations{{ Operations: []v1alpha1.OperationType{v1alpha1.Create}, Rule: v1alpha1.Rule{ @@ -237,6 +243,36 @@ func registerWebhook(f *framework.Framework, context *certContext) { Namespace: namespace, Name: serviceName, }, + URLPath: "/pods", + CABundle: context.signingCert, + }, + }, + { + Name: "deny-unwanted-configmap-data.k8s.io", + Rules: []v1alpha1.RuleWithOperations{{ + Operations: []v1alpha1.OperationType{v1alpha1.Create}, + Rule: v1alpha1.Rule{ + APIGroups: []string{""}, + APIVersions: []string{"v1"}, + Resources: []string{"configmaps"}, + }, + }}, + // The webhook skips the namespace that has label "skip-webhook-admission":"yes" + NamespaceSelector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: skipNamespaceLabelKey, + Operator: metav1.LabelSelectorOpNotIn, + Values: []string{skipNamespaceLabelValue}, + }, + }, + }, + ClientConfig: v1alpha1.WebhookClientConfig{ + Service: v1alpha1.ServiceReference{ + Namespace: namespace, + Name: serviceName, + }, + URLPath: "/configmaps", CABundle: context.signingCert, }, }, @@ -262,12 +298,45 @@ func testWebhook(f *framework.Framework) { // TODO: Test if webhook can detect pod with non-compliant metadata. // Currently metadata is lost because webhook uses the external version of // the objects, and the apiserver sends the internal objects. + + By("create a configmap that should be denied by the webhook") + // Creating the configmap, the request should be rejected + configmap := nonCompliantConfigMap(f) + _, err = client.CoreV1().ConfigMaps(f.Namespace.Name).Create(configmap) + Expect(err).NotTo(BeNil()) + expectedErrMsg = "the configmap contains unwanted key and value" + if !strings.Contains(err.Error(), expectedErrMsg) { + framework.Failf("expect error contains %q, got %q", expectedErrMsg, err.Error()) + } + + By("create a namespace that bypass the webhook") + err = wait.Poll(100*time.Millisecond, 30*time.Second, func() (bool, error) { + _, err2 := client.CoreV1().Namespaces().Create(&v1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: skippedNamespaceName, + Labels: map[string]string{ + skipNamespaceLabelKey: skipNamespaceLabelValue, + }, + }}) + if err2 != nil { + if strings.HasPrefix(err2.Error(), "object is being deleted:") { + return false, nil + } + return false, err2 + } + return true, nil + }) + framework.ExpectNoError(err, "creating namespace %q", skippedNamespaceName) + + By("create a configmap that violates the webhook policy but is in a whitelisted namespace") + configmap = nonCompliantConfigMap(f) + _, err = client.CoreV1().ConfigMaps(skippedNamespaceName).Create(configmap) + Expect(err).To(BeNil()) } func nonCompliantPod(f *framework.Framework) *v1.Pod { return &v1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "disallowed-pod", + Name: disallowedPodName, Labels: map[string]string{ "webhook-e2e-test": "disallow", }, @@ -283,6 +352,17 @@ func nonCompliantPod(f *framework.Framework) *v1.Pod { } } +func nonCompliantConfigMap(f *framework.Framework) *v1.ConfigMap { + return &v1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: disallowedConfigMapName, + }, + Data: map[string]string{ + "webhook-e2e-test": "webhook-disallow", + }, + } +} + func cleanWebhookTest(f *framework.Framework) { client := f.ClientSet _ = client.AdmissionregistrationV1alpha1().ValidatingWebhookConfigurations().Delete(webhookConfigName, nil) @@ -291,4 +371,6 @@ func cleanWebhookTest(f *framework.Framework) { _ = client.ExtensionsV1beta1().Deployments(namespaceName).Delete(deploymentName, nil) _ = client.CoreV1().Secrets(namespaceName).Delete(secretName, nil) _ = client.RbacV1beta1().RoleBindings("kube-system").Delete(roleBindingName, nil) + _ = client.CoreV1().ConfigMaps(skippedNamespaceName).Delete(disallowedConfigMapName, nil) + _ = client.CoreV1().Namespaces().Delete(skippedNamespaceName, nil) } diff --git a/test/images/webhook/Makefile b/test/images/webhook/Makefile index 7f706cbaf1..84b76aade1 100644 --- a/test/images/webhook/Makefile +++ b/test/images/webhook/Makefile @@ -14,6 +14,6 @@ build: CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o webhook . - docker build --no-cache -t gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v1 . + docker build --no-cache -t gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v2 . push: - gcloud docker --push gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v1 . + gcloud docker -- push gcr.io/kubernetes-e2e-test-images/k8s-sample-admission-webhook-amd64:1.8v2 diff --git a/test/images/webhook/main.go b/test/images/webhook/main.go index f6e6500e5f..bb4065196b 100644 --- a/test/images/webhook/main.go +++ b/test/images/webhook/main.go @@ -44,7 +44,8 @@ func (c *Config) addFlags() { } // only allow pods to pull images from specific registry. -func admit(data []byte) *v1alpha1.AdmissionReviewStatus { +func admitPods(data []byte) *v1alpha1.AdmissionReviewStatus { + glog.V(2).Info("admitting pods") ar := v1alpha1.AdmissionReview{} if err := json.Unmarshal(data, &ar); err != nil { glog.Error(err) @@ -86,7 +87,42 @@ func admit(data []byte) *v1alpha1.AdmissionReviewStatus { return &reviewStatus } -func serve(w http.ResponseWriter, r *http.Request) { +// deny configmaps with specific key-value pair. +func admitConfigMaps(data []byte) *v1alpha1.AdmissionReviewStatus { + glog.V(2).Info("admitting configmaps") + ar := v1alpha1.AdmissionReview{} + if err := json.Unmarshal(data, &ar); err != nil { + glog.Error(err) + return nil + } + configMapResource := metav1.GroupVersionResource{Group: "", Version: "v1", Resource: "configmaps"} + if ar.Spec.Resource != configMapResource { + glog.Errorf("expect resource to be %s", configMapResource) + return nil + } + + raw := ar.Spec.Object.Raw + configmap := v1.ConfigMap{} + if err := json.Unmarshal(raw, &configmap); err != nil { + glog.Error(err) + return nil + } + reviewStatus := v1alpha1.AdmissionReviewStatus{} + reviewStatus.Allowed = true + for k, v := range configmap.Data { + if k == "webhook-e2e-test" && v == "webhook-disallow" { + reviewStatus.Allowed = false + reviewStatus.Result = &metav1.Status{ + Reason: "the configmap contains unwanted key and value", + } + } + } + return &reviewStatus +} + +type admitFunc func(data []byte) *v1alpha1.AdmissionReviewStatus + +func serve(w http.ResponseWriter, r *http.Request, admit admitFunc) { var body []byte if r.Body != nil { if data, err := ioutil.ReadAll(r.Body); err == nil { @@ -102,8 +138,10 @@ func serve(w http.ResponseWriter, r *http.Request) { } reviewStatus := admit(body) - ar := v1alpha1.AdmissionReview{ - Status: *reviewStatus, + + ar := v1alpha1.AdmissionReview{} + if reviewStatus != nil { + ar.Status = *reviewStatus } resp, err := json.Marshal(ar) @@ -115,12 +153,20 @@ func serve(w http.ResponseWriter, r *http.Request) { } } +func servePods(w http.ResponseWriter, r *http.Request) { + serve(w, r, admitPods) +} +func serveConfigmaps(w http.ResponseWriter, r *http.Request) { + serve(w, r, admitConfigMaps) +} + func main() { var config Config config.addFlags() flag.Parse() - http.HandleFunc("/", serve) + http.HandleFunc("/pods", servePods) + http.HandleFunc("/configmaps", serveConfigmaps) clientset := getClient() server := &http.Server{ Addr: ":443",