pkg/webhook/resourcesemantics/validation/validation_admit_test.go

726 lines
19 KiB
Go

/*
Copyright 2020 The Knative 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 validation
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
// Injection stuff
_ "knative.dev/pkg/client/injection/kube/client/fake"
_ "knative.dev/pkg/client/injection/kube/informers/admissionregistration/v1/validatingwebhookconfiguration/fake"
_ "knative.dev/pkg/injection/clients/namespacedkube/informers/core/v1/secret/fake"
pkgreconciler "knative.dev/pkg/reconciler"
admissionv1 "k8s.io/api/admission/v1"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1beta1"
authenticationv1 "k8s.io/api/authentication/v1"
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/apimachinery/pkg/util/wait"
fakekubeclientset "k8s.io/client-go/kubernetes/fake"
"knative.dev/pkg/apis"
"knative.dev/pkg/system"
"knative.dev/pkg/webhook"
_ "knative.dev/pkg/system/testing"
. "knative.dev/pkg/logging/testing"
. "knative.dev/pkg/reconciler/testing"
. "knative.dev/pkg/testing"
"knative.dev/pkg/webhook/resourcesemantics"
. "knative.dev/pkg/webhook/testing"
)
const (
testResourceValidationPath = "/foo"
testResourceValidationName = "webhook.knative.dev"
user1 = "brutto@knative.dev"
user2 = "arrabbiato@knative.dev"
)
var (
handlers = map[schema.GroupVersionKind]resourcesemantics.GenericCRD{
{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
}: &Resource{},
{
Group: "pkg.knative.dev",
Version: "v1beta1",
Kind: "Resource",
}: &Resource{},
{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "InnerDefaultResource",
}: &InnerDefaultResource{},
{
Group: "pkg.knative.io",
Version: "v1alpha1",
Kind: "InnerDefaultResource",
}: &InnerDefaultResource{},
}
callbacks = map[schema.GroupVersionKind]Callback{
{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
}: NewCallback(resourceCallback, webhook.Create, webhook.Update, webhook.Delete),
{
Group: "pkg.knative.dev",
Version: "v1beta1",
Kind: "Resource",
}: NewCallback(resourceCallback, webhook.Create, webhook.Update, webhook.Delete),
{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "CallbackResource",
}: NewCallback(resourceCallback, webhook.Create, webhook.Update, webhook.Delete),
}
initialResourceWebhook = &admissionregistrationv1.ValidatingWebhookConfiguration{
ObjectMeta: metav1.ObjectMeta{
Name: "webhook.knative.dev",
OwnerReferences: []metav1.OwnerReference{{
Name: "asdf",
}},
},
Webhooks: []admissionregistrationv1.ValidatingWebhook{{
Name: "webhook.knative.dev",
ClientConfig: admissionregistrationv1.WebhookClientConfig{
Service: &admissionregistrationv1.ServiceReference{
Namespace: system.Namespace(),
Name: "webhook",
},
},
}},
}
)
func newNonRunningTestResourceAdmissionController(t *testing.T) (
kubeClient *fakekubeclientset.Clientset,
ac webhook.AdmissionController) {
t.Helper()
// Create fake clients
kubeClient = fakekubeclientset.NewSimpleClientset(initialResourceWebhook)
ac = newTestResourceAdmissionController(t)
return
}
func TestDeleteAllowed(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Delete,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
}
if resp := ac.Admit(TestContextWithLogger(t), req); !resp.Allowed {
t.Fatal("Unexpected denial of delete")
}
}
func TestConnectAllowed(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Connect,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
}
resp := ac.Admit(TestContextWithLogger(t), req)
if !resp.Allowed {
t.Fatalf("Unexpected denial of connect")
}
}
func TestUnknownKindFails(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Garbage",
},
}
ExpectFailsWith(t, ac.Admit(TestContextWithLogger(t), req), "unhandled kind")
}
func TestUnknownVersionFails(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1beta2",
Kind: "Resource",
},
}
ExpectFailsWith(t, ac.Admit(TestContextWithLogger(t), req), "unhandled kind")
}
func TestUnknownFieldFails(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
}
marshaled, err := json.Marshal(map[string]interface{}{
"spec": map[string]interface{}{
"foo": "bar",
},
})
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.Object.Raw = marshaled
ExpectFailsWith(t, ac.Admit(TestContextWithLogger(t), req),
`decoding request failed: cannot decode incoming new object: json: unknown field "foo"`)
}
func TestUnknownMetadataFieldSucceeds(t *testing.T) {
_, ac := newNonRunningTestResourceAdmissionController(t)
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
}
marshaled, err := json.Marshal(map[string]interface{}{
"apiVersion": "pkg.knative.dev/v1alpha1",
"kind": "Resource",
"metadata": map[string]string{
"unknown": "property",
},
"spec": map[string]string{
"fieldWithValidation": "magic value",
},
})
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.Object.Raw = marshaled
ExpectAllowed(t, ac.Admit(TestContextWithLogger(t), req))
}
func TestAdmitCreates(t *testing.T) {
tests := []struct {
name string
setup func(context.Context, *Resource)
rejection string
}{{
name: "test simple creation (alpha, no diff)",
setup: func(ctx context.Context, r *Resource) {
r.TypeMeta.APIVersion = "v1alpha1"
r.SetDefaults(ctx)
r.Annotations = map[string]string{
"pkg.knative.dev/creator": user1,
"pkg.knative.dev/lastModifier": user1,
}
},
}, {
name: "test simple creation (beta, no diff)",
setup: func(ctx context.Context, r *Resource) {
r.TypeMeta.APIVersion = "v1beta1"
r.SetDefaults(ctx)
r.Annotations = map[string]string{
"pkg.knative.dev/creator": user1,
"pkg.knative.dev/lastModifier": user1,
}
},
}, {
name: "with bad field",
setup: func(ctx context.Context, r *Resource) {
// Put a bad value in.
r.Spec.FieldWithValidation = "not what's expected"
},
rejection: "invalid value",
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := CreateResource("a name")
ctx := apis.WithinCreate(apis.WithUserInfo(
TestContextWithLogger(t),
&authenticationv1.UserInfo{Username: user1}))
// Setup the resource.
tc.setup(ctx, r)
_, ac := newNonRunningTestResourceAdmissionController(t)
resp := ac.Admit(ctx, createCreateResource(ctx, t, r))
if tc.rejection == "" {
ExpectAllowed(t, resp)
} else {
ExpectFailsWith(t, resp, tc.rejection)
}
})
}
}
func resourceCallback(ctx context.Context, uns *unstructured.Unstructured) error {
var resource Resource
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(uns.UnstructuredContent(), &resource); err != nil {
return err
}
if apis.IsInDelete(ctx) {
if resource.Spec.FieldForCallbackValidation != "magic delete" {
return errors.New("no magic delete")
}
return nil
}
if apis.IsDryRun(ctx) {
return errors.New("dryRun fail")
}
if resource.Spec.FieldForCallbackValidation != "" &&
resource.Spec.FieldForCallbackValidation != "magic value" {
return errors.New(resource.Spec.FieldForCallbackValidation)
}
return nil
}
func TestValidationCreateCallback(t *testing.T) {
tests := []struct {
name string
dryRun bool
setup func(context.Context, *Resource)
rejection string
}{{
name: "with dryRun reject",
dryRun: true,
setup: func(ctx context.Context, r *Resource) {},
rejection: "validation callback failed: dryRun fail",
}, {
name: "with dryRun off",
dryRun: false,
setup: func(ctx context.Context, r *Resource) {},
}, {
name: "with field magic value",
dryRun: false,
setup: func(ctx context.Context, r *Resource) {
// Put a good value in.
r.Spec.FieldForCallbackValidation = "magic value"
},
}, {
name: "with field reject value",
dryRun: false,
setup: func(ctx context.Context, r *Resource) {
// Put a bad value in.
r.Spec.FieldForCallbackValidation = "callbacks hate this"
},
rejection: "validation callback failed: callbacks hate this",
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := CreateResource("a name")
ctx := apis.WithinCreate(apis.WithUserInfo(
TestContextWithLogger(t),
&authenticationv1.UserInfo{Username: user1}))
// Setup the resource.
tc.setup(ctx, r)
_, ac := newNonRunningTestResourceAdmissionController(t)
req := createCreateResource(ctx, t, r)
if tc.dryRun {
truePoint := true
req.DryRun = &truePoint
}
resp := ac.Admit(ctx, req)
if tc.rejection == "" {
ExpectAllowed(t, resp)
} else {
ExpectFailsWith(t, resp, tc.rejection)
}
})
}
}
func TestValidationDeleteCallback(t *testing.T) {
tests := []struct {
name string
setup func(context.Context, *Resource)
rejection string
}{{
name: "with field magic delete",
setup: func(ctx context.Context, r *Resource) {
// Put a good value in.
r.Spec.FieldForCallbackValidation = "magic delete"
},
}, {
name: "with field reject value",
setup: func(ctx context.Context, r *Resource) {
// Put a bad value in.
r.Spec.FieldForCallbackValidation = "no magic delete"
},
rejection: "validation callback failed: no magic delete",
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := CreateResource("a name")
ctx := apis.WithinCreate(apis.WithUserInfo(
TestContextWithLogger(t),
&authenticationv1.UserInfo{Username: user1}))
// Setup the resource.
tc.setup(ctx, r)
_, ac := newNonRunningTestResourceAdmissionController(t)
req := createDeleteResource(ctx, t, r)
resp := ac.Admit(ctx, req)
if tc.rejection == "" {
ExpectAllowed(t, resp)
} else {
ExpectFailsWith(t, resp, tc.rejection)
}
})
}
}
func createDeleteResource(ctx context.Context, t *testing.T, old *Resource) *admissionv1.AdmissionRequest {
t.Helper()
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Delete,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
UserInfo: *apis.GetUserInfo(ctx),
}
marshaledOld, err := json.Marshal(old)
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.OldObject.Raw = marshaledOld
req.Resource.Group = "pkg.knative.dev"
return req
}
func createCreateResource(ctx context.Context, t *testing.T, r *Resource) *admissionv1.AdmissionRequest {
t.Helper()
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Create,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
UserInfo: *apis.GetUserInfo(ctx),
}
marshaled, err := json.Marshal(r)
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.Object.Raw = marshaled
req.Resource.Group = "pkg.knative.dev"
return req
}
func TestAdmitUpdates(t *testing.T) {
tests := []struct {
name string
setup func(context.Context, *Resource)
mutate func(context.Context, *Resource)
subresource string
rejection string
}{{
name: "test simple update (no diff)",
setup: func(ctx context.Context, r *Resource) {
r.SetDefaults(ctx)
},
mutate: func(ctx context.Context, r *Resource) {
// If we don't change anything, the updater
// annotation doesn't change.
},
}, {
name: "bad mutation (immutable)",
setup: func(ctx context.Context, r *Resource) {
r.SetDefaults(ctx)
},
mutate: func(ctx context.Context, r *Resource) {
r.Spec.FieldThatsImmutableWithDefault = "something different"
},
rejection: "Immutable field changed",
}, {
name: "bad mutation (validation)",
setup: func(ctx context.Context, r *Resource) {
r.SetDefaults(ctx)
},
mutate: func(ctx context.Context, r *Resource) {
r.Spec.FieldWithValidation = "not what's expected"
},
rejection: "invalid value",
}, {
name: "bad mutation (invalid subresource)",
setup: func(ctx context.Context, r *Resource) {
r.SetDefaults(ctx)
},
mutate: func(ctx context.Context, r *Resource) {
},
subresource: "badbadsubresource",
rejection: "validation failed: Disallowed subresource update: \nDisallowed subresource update: badbadsubresource",
}, {
name: "good mutation with valid subresource",
setup: func(ctx context.Context, r *Resource) {
r.SetDefaults(ctx)
},
mutate: func(ctx context.Context, r *Resource) {
},
subresource: "goodgoodsubresource",
}}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
old := CreateResource("a name")
ctx := TestContextWithLogger(t)
old.Annotations = map[string]string{
"pkg.knative.dev/creator": user1,
"pkg.knative.dev/lastModifier": user1,
}
tc.setup(ctx, old)
new := old.DeepCopy()
// Mutate the resource using the update context as user2
ctx = apis.WithUserInfo(apis.WithinUpdate(ctx, old),
&authenticationv1.UserInfo{Username: user2})
tc.mutate(ctx, new)
_, ac := newNonRunningTestResourceAdmissionController(t)
resp := ac.Admit(ctx, createUpdateResource(ctx, t, old, new, tc.subresource))
if tc.rejection == "" {
ExpectAllowed(t, resp)
} else {
ExpectFailsWith(t, resp, tc.rejection)
}
})
}
}
func createUpdateResource(ctx context.Context, t *testing.T, old, new *Resource, subresource string) *admissionv1.AdmissionRequest {
t.Helper()
req := &admissionv1.AdmissionRequest{
Operation: admissionv1.Update,
Kind: metav1.GroupVersionKind{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
},
UserInfo: *apis.GetUserInfo(ctx),
SubResource: subresource,
}
marshaled, err := json.Marshal(new)
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.Object.Raw = marshaled
marshaledOld, err := json.Marshal(old)
if err != nil {
t.Fatal("Failed to marshal resource:", err)
}
req.OldObject.Raw = marshaledOld
req.Resource.Group = "pkg.knative.dev"
return req
}
func createInnerDefaultResourceWithoutSpec(t *testing.T) []byte {
t.Helper()
r := InnerDefaultResource{
TypeMeta: metav1.TypeMeta{
Kind: "testKind",
APIVersion: "testAPIVersion",
},
ObjectMeta: metav1.ObjectMeta{
Namespace: system.Namespace(),
Name: "a name",
},
}
// Remove the 'spec' field of the generated JSON by marshaling it to JSON, parsing that as a
// generic map[string]interface{}, removing 'spec', and marshaling it again.
origBytes, err := json.Marshal(r)
if err != nil {
t.Fatal("Error marshaling origBytes:", err)
}
var q map[string]interface{}
if err := json.Unmarshal(origBytes, &q); err != nil {
t.Fatal("Error unmarshaling origBytes:", err)
}
delete(q, "spec")
b, err := json.Marshal(q)
if err != nil {
t.Fatal("Error marshaling q:", err)
}
return b
}
func createInnerDefaultResourceWithSpecAndStatus(t *testing.T, spec *InnerDefaultSpec, status *InnerDefaultStatus) []byte {
t.Helper()
r := InnerDefaultResource{
TypeMeta: metav1.TypeMeta{
Kind: "testKind",
APIVersion: "testAPIVersion",
},
ObjectMeta: metav1.ObjectMeta{
Namespace: system.Namespace(),
Name: "a name",
},
}
if spec != nil {
r.Spec = *spec
}
if status != nil {
r.Status = *status
}
b, err := json.Marshal(r)
if err != nil {
t.Fatal("Error marshaling bytes:", err)
}
return b
}
func TestNewResourceAdmissionController(t *testing.T) {
ctx, _ := SetupFakeContext(t)
defer func() {
if r := recover(); r == nil {
t.Errorf("Expected a second callback to panic")
}
}()
invalidSecondCallback := map[schema.GroupVersionKind]Callback{}
NewAdmissionController(
ctx, testResourceValidationName, testResourceValidationPath,
handlers,
func(ctx context.Context) context.Context {
return ctx
}, true,
callbacks,
invalidSecondCallback)
}
func TestNewResourceAdmissionControllerDuplicateVerb(t *testing.T) {
ctx, _ := SetupFakeContext(t)
defer func() {
if r := recover(); r == nil {
t.Errorf("Expected a second callback to panic")
}
}()
call := map[schema.GroupVersionKind]Callback{
{
Group: "pkg.knative.dev",
Version: "v1alpha1",
Kind: "Resource",
}: NewCallback(resourceCallback, webhook.Create, webhook.Create), // Disallow duplicates under test
}
NewAdmissionController(
ctx, testResourceValidationName, testResourceValidationPath,
handlers,
func(ctx context.Context) context.Context {
return ctx
}, true,
call)
}
func newTestResourceAdmissionController(t *testing.T) webhook.AdmissionController {
ctx, _ := SetupFakeContext(t)
ctx = webhook.WithOptions(ctx, webhook.Options{
SecretName: "webhook-secret",
})
c := NewAdmissionControllerWithConfig(
ctx, testResourceValidationName, testResourceValidationPath,
handlers,
func(ctx context.Context) context.Context {
return ctx
}, true, callbacks)
if c == nil {
t.Fatal("Expected NewController to return a non-nil value")
}
if want, got := 0, c.WorkQueue().Len(); want != got {
t.Errorf("WorkQueue.Len() = %d, wanted %d", got, want)
}
la, ok := c.Reconciler.(pkgreconciler.LeaderAware)
if !ok {
t.Fatalf("%T is not leader aware", c.Reconciler)
}
if err := la.Promote(pkgreconciler.UniversalBucket(), c.MaybeEnqueueBucketKey); err != nil {
t.Error("Promote() =", err)
}
// Queue has async moving parts so if we check at the wrong moment, this might still be 0.
if wait.PollUntilContextTimeout(ctx, 10*time.Millisecond, 250*time.Millisecond, true, func(ctx context.Context) (bool, error) {
return c.WorkQueue().Len() == 1, nil
}) != nil {
t.Error("Queue length was never 1")
}
return c.Reconciler.(*reconciler)
}