diff --git a/testing/resource.go b/testing/resource.go index d562b7ce9d..e26ca317e4 100644 --- a/testing/resource.go +++ b/testing/resource.go @@ -23,6 +23,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + "knative.dev/pkg/apis" ) @@ -43,12 +44,15 @@ var _ apis.Listable = (*Resource)(nil) // ResourceSpec represents test resource spec. type ResourceSpec struct { - FieldWithDefault string `json:"fieldWithDefault,omitempty"` - FieldWithContextDefault string `json:"fieldWithContextDefault,omitempty"` - FieldWithValidation string `json:"fieldWithValidation,omitempty"` - FieldThatsImmutable string `json:"fieldThatsImmutable,omitempty"` - FieldThatsImmutableWithDefault string `json:"fieldThatsImmutableWithDefault,omitempty"` - FieldForCallbackValidation string `json:"fieldThatCallbackRejects,omitempty"` + FieldWithDefault string `json:"fieldWithDefault,omitempty"` + FieldWithContextDefault string `json:"fieldWithContextDefault,omitempty"` + FieldWithValidation string `json:"fieldWithValidation,omitempty"` + FieldThatsImmutable string `json:"fieldThatsImmutable,omitempty"` + FieldThatsImmutableWithDefault string `json:"fieldThatsImmutableWithDefault,omitempty"` + FieldForCallbackValidation string `json:"fieldThatCallbackRejects,omitempty"` + FieldForCallbackDefaulting string `json:"fieldForCallbackDefaulting,omitempty"` + FieldForCallbackDefaultingIsWithinUpdate bool `json:"fieldForCallbackDefaultingIsWithinUpdate,omitempty"` + FieldForCallbackDefaultingUsername string `json:"fieldForCallbackDefaultingUsername,omitempty"` } // GetGroupVersionKind returns the GroupVersionKind. @@ -56,6 +60,16 @@ func (r *Resource) GetGroupVersionKind() schema.GroupVersionKind { return SchemeGroupVersion.WithKind("Resource") } +// GetGroupVersionKindMeta returns the metav1.GroupVersionKind. +func (r *Resource) GetGroupVersionKindMeta() metav1.GroupVersionKind { + gvk := r.GroupVersionKind() + return metav1.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + } +} + // GetUntypedSpec returns the spec of the resource. func (r *Resource) GetUntypedSpec() interface{} { return r.Spec diff --git a/webhook/resourcesemantics/defaulting/controller.go b/webhook/resourcesemantics/defaulting/controller.go index a4a41d0bea..b50fcf8f41 100644 --- a/webhook/resourcesemantics/defaulting/controller.go +++ b/webhook/resourcesemantics/defaulting/controller.go @@ -29,6 +29,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/cache" + "knative.dev/pkg/controller" "knative.dev/pkg/system" "knative.dev/pkg/webhook" @@ -42,6 +43,7 @@ func NewAdmissionController( handlers map[schema.GroupVersionKind]resourcesemantics.GenericCRD, wc func(context.Context) context.Context, disallowUnknownFields bool, + callbacks ...map[schema.GroupVersionKind]Callback, ) *controller.Impl { client := kubeclient.Get(ctx) @@ -51,6 +53,19 @@ func NewAdmissionController( key := types.NamespacedName{Name: name} + // This not ideal, we are using a variadic argument to effectively make callbacks optional + // This allows this addition to be non-breaking to consumers of /pkg + // TODO: once all sub-repos have adopted this, we might move this back to a traditional param. + var unwrappedCallbacks map[schema.GroupVersionKind]Callback + switch len(callbacks) { + case 0: + unwrappedCallbacks = map[schema.GroupVersionKind]Callback{} + case 1: + unwrappedCallbacks = callbacks[0] + default: + panic("NewAdmissionController may not be called with multiple callback maps") + } + wh := &reconciler{ LeaderAwareFuncs: pkgreconciler.LeaderAwareFuncs{ // Have this reconciler enqueue our singleton whenever it becomes leader. @@ -60,9 +75,10 @@ func NewAdmissionController( }, }, - key: key, - path: path, - handlers: handlers, + key: key, + path: path, + handlers: handlers, + callbacks: unwrappedCallbacks, withContext: wc, disallowUnknownFields: disallowUnknownFields, diff --git a/webhook/resourcesemantics/defaulting/defaulting.go b/webhook/resourcesemantics/defaulting/defaulting.go index 504dcaa066..a8a30fae13 100644 --- a/webhook/resourcesemantics/defaulting/defaulting.go +++ b/webhook/resourcesemantics/defaulting/defaulting.go @@ -25,16 +25,18 @@ import ( "github.com/gobuffalo/flect" "go.uber.org/zap" - jsonpatch "gomodules.xyz/jsonpatch/v2" + "gomodules.xyz/jsonpatch/v2" admissionv1 "k8s.io/api/admission/v1" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" admissionlisters "k8s.io/client-go/listers/admissionregistration/v1" corelisters "k8s.io/client-go/listers/core/v1" + "knative.dev/pkg/apis" "knative.dev/pkg/apis/duck" "knative.dev/pkg/controller" @@ -56,9 +58,10 @@ type reconciler struct { webhook.StatelessAdmissionImpl pkgreconciler.LeaderAwareFuncs - key types.NamespacedName - path string - handlers map[schema.GroupVersionKind]resourcesemantics.GenericCRD + key types.NamespacedName + path string + handlers map[schema.GroupVersionKind]resourcesemantics.GenericCRD + callbacks map[schema.GroupVersionKind]Callback withContext func(context.Context) context.Context @@ -70,6 +73,37 @@ type reconciler struct { secretName string } +// CallbackFunc is the function to be invoked. +type CallbackFunc func(ctx context.Context, unstructured *unstructured.Unstructured) error + +// Callback is a generic function to be called by a consumer of defaulting. +type Callback struct { + // function is the callback to be invoked. + function CallbackFunc + + // supportedVerbs are the verbs supported for the callback. + // The function will only be called on these actions. + supportedVerbs map[webhook.Operation]struct{} +} + +// NewCallback creates a new callback function to be invoked on supported verbs. +func NewCallback(function func(context.Context, *unstructured.Unstructured) error, supportedVerbs ...webhook.Operation) Callback { + if function == nil { + panic("expected function, got nil") + } + m := make(map[webhook.Operation]struct{}) + for _, op := range supportedVerbs { + if op == webhook.Delete { + panic("Verb " + webhook.Delete + " not allowed") + } + if _, has := m[op]; has { + panic("duplicate verbs not allowed") + } + m[op] = struct{}{} + } + return Callback{function: function, supportedVerbs: m} +} + var _ controller.Reconciler = (*reconciler)(nil) var _ pkgreconciler.LeaderAware = (*reconciler)(nil) var _ webhook.AdmissionController = (*reconciler)(nil) @@ -137,7 +171,17 @@ func (ac *reconciler) reconcileMutatingWebhook(ctx context.Context, caCert []byt logger := logging.FromContext(ctx) rules := make([]admissionregistrationv1.RuleWithOperations, 0, len(ac.handlers)) + gvks := make(map[schema.GroupVersionKind]struct{}, len(ac.handlers)+len(ac.callbacks)) for gvk := range ac.handlers { + gvks[gvk] = struct{}{} + } + for gvk := range ac.callbacks { + if _, ok := gvks[gvk]; !ok { + gvks[gvk] = struct{}{} + } + } + + for gvk := range gvks { plural := strings.ToLower(flect.Pluralize(gvk.Kind)) rules = append(rules, admissionregistrationv1.RuleWithOperations{ @@ -231,8 +275,18 @@ func (ac *reconciler) mutate(ctx context.Context, req *admissionv1.AdmissionRequ logger := logging.FromContext(ctx) handler, ok := ac.handlers[gvk] if !ok { - logger.Error("Unhandled kind: ", gvk) - return nil, fmt.Errorf("unhandled kind: %v", gvk) + if _, ok := ac.callbacks[gvk]; !ok { + logger.Error("Unhandled kind: ", gvk) + return nil, fmt.Errorf("unhandled kind: %v", gvk) + } + patches, err := ac.callback(ctx, gvk, req, true /* shouldSetUserInfo */, duck.JSONPatch{}) + if err != nil { + logger.Errorw("Failed the callback defaulter", zap.Error(err)) + // Return the error message as-is to give the defaulter callback + // discretion over (our portion of) the message that the user sees. + return nil, err + } + return json.Marshal(patches) } // nil values denote absence of `old` (create) or `new` (delete) objects. @@ -302,6 +356,13 @@ func (ac *reconciler) mutate(ctx context.Context, req *admissionv1.AdmissionRequ return nil, err } + if patches, err = ac.callback(ctx, gvk, req, false /* shouldSetUserInfo */, patches); err != nil { + logger.Errorw("Failed the callback defaulter", zap.Error(err)) + // Return the error message as-is to give the defaulter callback + // discretion over (our portion of) the message that the user sees. + return nil, err + } + // None of the validators will accept a nil value for newObj. if newObj == nil { return nil, errMissingNewObject @@ -329,6 +390,57 @@ func (ac *reconciler) setUserInfoAnnotations(ctx context.Context, patches duck.J return append(patches, patch...), nil } +func (ac *reconciler) callback(ctx context.Context, gvk schema.GroupVersionKind, req *admissionv1.AdmissionRequest, shouldSetUserInfo bool, patches duck.JSONPatch) (duck.JSONPatch, error) { + // Get callback. + callback, ok := ac.callbacks[gvk] + if !ok { + return patches, nil + } + + // Check if request operation is a supported webhook operation. + if _, isSupported := callback.supportedVerbs[req.Operation]; !isSupported { + return patches, nil + } + + oldBytes := req.OldObject.Raw + newBytes := req.Object.Raw + + before := &unstructured.Unstructured{} + after := &unstructured.Unstructured{} + + // Get unstructured object. + if err := json.Unmarshal(newBytes, before); err != nil { + return nil, fmt.Errorf("cannot decode object: %w", err) + } + // Copy before in after unstructured objects. + before.DeepCopyInto(after) + + // Setup context. + if len(oldBytes) != 0 { + if req.SubResource == "" { + ctx = apis.WithinUpdate(ctx, before) + } else { + ctx = apis.WithinSubResourceUpdate(ctx, before, req.SubResource) + } + } else { + ctx = apis.WithinCreate(ctx) + } + ctx = apis.WithUserInfo(ctx, &req.UserInfo) + + // Call callback passing after. + if err := callback.function(ctx, after); err != nil { + return patches, err + } + + if shouldSetUserInfo { + setUserInfoAnnotations(adaptUnstructuredHasSpecCtx(ctx, req), unstructuredHasSpec{after}, req.Resource.Group) + } + + // Create patches. + patch, err := duck.CreatePatch(before.Object, after.Object) + return append(patches, patch...), err +} + // roundTripPatch generates the JSONPatch that corresponds to round tripping the given bytes through // the Golang type (JSON -> Golang type -> JSON). Because it is not always true that // bytes == json.Marshal(json.Unmarshal(bytes)). diff --git a/webhook/resourcesemantics/defaulting/defaulting_test.go b/webhook/resourcesemantics/defaulting/defaulting_test.go index dd52307621..fc8ba71ec7 100644 --- a/webhook/resourcesemantics/defaulting/defaulting_test.go +++ b/webhook/resourcesemantics/defaulting/defaulting_test.go @@ -19,14 +19,20 @@ package defaulting import ( "context" "encoding/json" + "errors" + "fmt" + "reflect" "testing" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + // Injection stuff _ "knative.dev/pkg/client/injection/kube/client/fake" _ "knative.dev/pkg/client/injection/kube/informers/admissionregistration/v1/mutatingwebhookconfiguration/fake" _ "knative.dev/pkg/injection/clients/namespacedkube/informers/core/v1/secret/fake" - jsonpatch "gomodules.xyz/jsonpatch/v2" + "gomodules.xyz/jsonpatch/v2" admissionv1 "k8s.io/api/admission/v1" admissionregistrationv1 "k8s.io/api/admissionregistration/v1" authenticationv1 "k8s.io/api/authentication/v1" @@ -78,6 +84,29 @@ var ( }: &InnerDefaultResource{}, } + callbacks = map[schema.GroupVersionKind]Callback{ + { + Group: "pkg.knative.dev", + Version: "v1alpha1", + Kind: "Resource", + }: NewCallback(resourceCallback, webhook.Create, webhook.Update), + { + Group: "pkg.knative.dev", + Version: "v1beta1", + Kind: "Resource", + }: NewCallback(resourceCallback, webhook.Create, webhook.Update), + { + Group: "pkg.knative.dev", + Version: "v1beta1", + Kind: "ResourceCallbackDefault", + }: NewCallback(resourceCallback, webhook.Create, webhook.Update), + { + Group: "pkg.knative.dev", + Version: "v1beta1", + Kind: "ResourceCallbackDefaultCreate", + }: NewCallback(resourceCallback, webhook.Create), + } + initialResourceWebhook = &admissionregistrationv1.MutatingWebhookConfiguration{ ObjectMeta: metav1.ObjectMeta{ Name: "webhook.knative.dev", @@ -218,10 +247,11 @@ func TestUnknownMetadataFieldSucceeds(t *testing.T) { func TestAdmitCreates(t *testing.T) { tests := []struct { - name string - setup func(context.Context, *Resource) - rejection string - patches []jsonpatch.JsonPatchOperation + name string + setup func(context.Context, *Resource) + rejection string + patches []jsonpatch.JsonPatchOperation + createRequestFunc func(ctx context.Context, t *testing.T, r *Resource) *admissionv1.AdmissionRequest }{{ name: "test simple creation (alpha, no diff)", setup: func(ctx context.Context, r *Resource) { @@ -323,6 +353,72 @@ func TestAdmitCreates(t *testing.T) { Path: "/metadata/annotations/pkg.knative.dev~1creator", Value: user1, }}, + }, { + name: "test simple creation (callback return error)", + setup: func(ctx context.Context, resource *Resource) { + resource.Spec.FieldForCallbackDefaulting = "no magic value" + }, + rejection: "no magic value", + }, { + name: "test simple creation (resource and callback defaults)", + setup: func(ctx context.Context, r *Resource) { + r.SetDefaults(ctx) + r.Spec.FieldForCallbackDefaulting = "magic value" + // THIS IS NOT WHO IS CREATING IT, IT IS LIES! + r.Annotations = map[string]string{ + "pkg.knative.dev/lastModifier": user2, + } + }, + patches: []jsonpatch.JsonPatchOperation{{ + Operation: "replace", + Path: "/spec/fieldForCallbackDefaulting", + Value: "I'm a default", + }, { + Operation: "replace", + Path: "/metadata/annotations/pkg.knative.dev~1lastModifier", + Value: user1, + }, { + Operation: "add", + Path: "/metadata/annotations/pkg.knative.dev~1creator", + Value: user1, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingUsername", + Value: user1, + }}, + }, { + name: "test simple creation (only callback defaults)", + setup: func(ctx context.Context, r *Resource) { + r.TypeMeta.APIVersion = "pkg.knative.dev/v1beta1" + r.TypeMeta.Kind = "ResourceCallbackDefault" + r.Spec.FieldForCallbackDefaulting = "magic value" + // THIS IS NOT WHO IS CREATING IT, IT LIES! + r.Annotations = map[string]string{ + "pkg.knative.dev/lastModifier": user2, + } + }, + createRequestFunc: func(ctx context.Context, t *testing.T, r *Resource) *admissionv1.AdmissionRequest { + req := createCreateResource(ctx, t, r) + req.Kind = r.GetGroupVersionKindMeta() + return req + }, + patches: []jsonpatch.JsonPatchOperation{{ + Operation: "replace", + Path: "/spec/fieldForCallbackDefaulting", + Value: "I'm a default", + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingUsername", + Value: user1, + }, { + Operation: "replace", + Path: "/metadata/annotations/pkg.knative.dev~1lastModifier", + Value: user1, + }, { + Operation: "add", + Path: "/metadata/annotations/pkg.knative.dev~1creator", + Value: user1, + }}, }} for _, tc := range tests { @@ -336,7 +432,13 @@ func TestAdmitCreates(t *testing.T) { tc.setup(ctx, r) _, ac := newNonRunningTestResourceAdmissionController(t) - resp := ac.Admit(ctx, createCreateResource(ctx, t, r)) + var req *admissionv1.AdmissionRequest + if tc.createRequestFunc == nil { + req = createCreateResource(ctx, t, r) + } else { + req = tc.createRequestFunc(ctx, t, r) + } + resp := ac.Admit(ctx, req) if tc.rejection == "" { ExpectAllowed(t, resp) @@ -385,6 +487,15 @@ func TestAdmitUpdates(t *testing.T) { // annotation doesn't change. }, patches: []jsonpatch.JsonPatchOperation{}, + }, { + name: "test simple update (callback defaults error)", + setup: func(ctx context.Context, r *Resource) { + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "no magic value" + }, + rejection: "no magic value", }, { name: "test simple update (update updater annotation)", setup: func(ctx context.Context, r *Resource) { @@ -428,7 +539,9 @@ func TestAdmitUpdates(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - old := CreateResource("a name") + name := "a name" + + old := CreateResource(name) ctx := TestContextWithLogger(t) old.Annotations = map[string]string{ @@ -446,7 +559,174 @@ func TestAdmitUpdates(t *testing.T) { tc.mutate(ctx, new) _, ac := newNonRunningTestResourceAdmissionController(t) - resp := ac.Admit(ctx, createUpdateResource(ctx, t, old, new)) + + req := createUpdateResource(ctx, t, old, new) + + resp := ac.Admit(ctx, req) + + if tc.rejection == "" { + ExpectAllowed(t, resp) + ExpectPatches(t, resp.Patch, tc.patches) + } else { + ExpectFailsWith(t, resp, tc.rejection) + } + }) + } +} + +func TestAdmitUpdatesCallback(t *testing.T) { + tests := []struct { + name string + setup func(context.Context, *Resource) + mutate func(context.Context, *Resource) + createResourceFunc func(name string) *Resource + createUpdateResourceFunc func(ctx context.Context, t *testing.T, old, new *Resource) *admissionv1.AdmissionRequest + rejection string + patches []jsonpatch.JsonPatchOperation + }{ + { + name: "test simple update (callback defaults error)", + setup: func(ctx context.Context, r *Resource) { + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "no magic value" + }, + rejection: "no magic value", + createUpdateResourceFunc: createUpdateResource, + }, { + name: "test simple update (callback defaults)", + setup: func(ctx context.Context, r *Resource) { + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "magic value" + }, + patches: []jsonpatch.JsonPatchOperation{{ + Operation: "replace", + Path: "/spec/fieldForCallbackDefaulting", + Value: "I'm a default", + }, { + + Operation: "replace", + Path: "/metadata/annotations/pkg.knative.dev~1lastModifier", + Value: user2, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingIsWithinUpdate", + Value: true, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingUsername", + Value: user2, + }}, + createUpdateResourceFunc: createUpdateResource, + }, { + name: "test simple update (callback defaults only)", + setup: func(ctx context.Context, r *Resource) { + r.TypeMeta.APIVersion = "pkg.knative.dev/v1beta1" + r.TypeMeta.Kind = "ResourceCallbackDefault" + r.Spec.FieldForCallbackDefaulting = "magic value" + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "magic value" + }, + createUpdateResourceFunc: func(ctx context.Context, t *testing.T, old, new *Resource) *admissionv1.AdmissionRequest { + req := createUpdateResource(ctx, t, old, new) + req.Kind = new.GetGroupVersionKindMeta() + return req + }, + patches: []jsonpatch.JsonPatchOperation{{ + Operation: "replace", + Path: "/metadata/annotations/pkg.knative.dev~1lastModifier", + Value: user2, + }, { + Operation: "replace", + Path: "/spec/fieldForCallbackDefaulting", + Value: "I'm a default", + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingIsWithinUpdate", + Value: true, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingUsername", + Value: user2, + }}, + }, { + name: "test simple update (callback defaults only, operation not supported)", + setup: func(ctx context.Context, r *Resource) { + r.TypeMeta.APIVersion = "pkg.knative.dev/v1beta1" + r.TypeMeta.Kind = "ResourceCallbackDefaultCreate" + r.Spec.FieldForCallbackDefaulting = "magic value" + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "magic value" + }, + createUpdateResourceFunc: func(ctx context.Context, t *testing.T, old, new *Resource) *admissionv1.AdmissionRequest { + req := createUpdateResource(ctx, t, old, new) + req.Operation = admissionv1.Update + req.Kind = new.GetGroupVersionKindMeta() + return req + }, + }, { + name: "test simple update (callback defaults)", + setup: func(ctx context.Context, r *Resource) { + r.SetDefaults(ctx) + }, + mutate: func(ctx context.Context, r *Resource) { + r.Spec.FieldForCallbackDefaulting = "magic value" + }, + patches: []jsonpatch.JsonPatchOperation{{ + Operation: "replace", + Path: "/spec/fieldForCallbackDefaulting", + Value: "I'm a default", + }, { + + Operation: "replace", + Path: "/metadata/annotations/pkg.knative.dev~1lastModifier", + Value: user2, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingIsWithinUpdate", + Value: true, + }, { + Operation: "add", + Path: "/spec/fieldForCallbackDefaultingUsername", + Value: user2, + }}, + createUpdateResourceFunc: createUpdateResource, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + name := "a name" + + old := CreateResource(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) + + req := tc.createUpdateResourceFunc(ctx, t, old, new) + + resp := ac.Admit(ctx, req) if tc.rejection == "" { ExpectAllowed(t, resp) @@ -511,6 +791,10 @@ func TestValidCreateResourceSucceedsWithRoundTripAndDefaultPatch(t *testing.T) { func createInnerDefaultResourceWithoutSpec(t *testing.T) []byte { t.Helper() r := InnerDefaultResource{ + TypeMeta: metav1.TypeMeta{ + Kind: "InnerDefaultResource", + APIVersion: fmt.Sprintf("%s/%s", SchemeGroupVersion.Group, SchemeGroupVersion.Version), + }, ObjectMeta: metav1.ObjectMeta{ Namespace: system.Namespace(), Name: "a name", @@ -543,5 +827,39 @@ func newTestResourceAdmissionController(t *testing.T) webhook.AdmissionControlle ctx, testResourceValidationName, testResourceValidationPath, handlers, func(ctx context.Context) context.Context { return ctx - }, true).Reconciler.(*reconciler) + }, true, callbacks).Reconciler.(*reconciler) +} + +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 resource.Spec.FieldForCallbackDefaulting != "" { + if resource.Spec.FieldForCallbackDefaulting != "magic value" { + return errors.New(resource.Spec.FieldForCallbackDefaulting) + } + resource.Spec.FieldForCallbackDefaultingIsWithinUpdate = apis.IsInUpdate(ctx) + resource.Spec.FieldForCallbackDefaultingUsername = apis.GetUserInfo(ctx).Username + resource.Spec.FieldForCallbackDefaulting = "I'm a default" + if apis.IsInUpdate(ctx) { + if apis.GetBaseline(ctx) == nil { + return fmt.Errorf("expected baseline object") + } + if v, ok := apis.GetBaseline(ctx).(*unstructured.Unstructured); !ok { + return fmt.Errorf("expected *unstructured.Unstructured, got %v", reflect.TypeOf(v)) + } + } else if !apis.IsInCreate(ctx) { + return fmt.Errorf("expected to have context within update or create") + } + } + + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&resource) + if err != nil { + return err + } + uns.Object = u + + return nil } diff --git a/webhook/resourcesemantics/defaulting/table_test.go b/webhook/resourcesemantics/defaulting/table_test.go index 1c7fa107db..6f5177cf00 100644 --- a/webhook/resourcesemantics/defaulting/table_test.go +++ b/webhook/resourcesemantics/defaulting/table_test.go @@ -33,6 +33,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" clientgotesting "k8s.io/client-go/testing" + "knative.dev/pkg/configmap" "knative.dev/pkg/controller" "knative.dev/pkg/ptr" @@ -91,6 +92,20 @@ func TestReconcile(t *testing.T) { APIVersions: []string{"v1alpha1"}, Resources: []string{"resources", "resources/status"}, }, + }, { + Operations: []admissionregistrationv1.OperationType{"CREATE", "UPDATE"}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"pkg.knative.dev"}, + APIVersions: []string{"v1beta1"}, + Resources: []string{"resourcecallbackdefaultcreates", "resourcecallbackdefaultcreates/status"}, + }, + }, { + Operations: []admissionregistrationv1.OperationType{"CREATE", "UPDATE"}, + Rule: admissionregistrationv1.Rule{ + APIGroups: []string{"pkg.knative.dev"}, + APIVersions: []string{"v1beta1"}, + Resources: []string{"resourcecallbackdefaults", "resourcecallbackdefaults/status"}, + }, }, { Operations: []admissionregistrationv1.OperationType{"CREATE", "UPDATE"}, Rule: admissionregistrationv1.Rule{ @@ -419,7 +434,8 @@ func TestReconcile(t *testing.T) { }, path: path, - handlers: handlers, + handlers: handlers, + callbacks: callbacks, client: kubeclient.Get(ctx), mwhlister: listers.GetMutatingWebhookConfigurationLister(), diff --git a/webhook/resourcesemantics/defaulting/user_info.go b/webhook/resourcesemantics/defaulting/user_info.go index 36c1cfbd21..fcd97a4561 100644 --- a/webhook/resourcesemantics/defaulting/user_info.go +++ b/webhook/resourcesemantics/defaulting/user_info.go @@ -19,8 +19,11 @@ package defaulting import ( "context" + admissionv1 "k8s.io/api/admission/v1" "k8s.io/apimachinery/pkg/api/equality" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "knative.dev/pkg/apis" ) @@ -48,5 +51,35 @@ func setUserInfoAnnotations(ctx context.Context, resource apis.HasSpec, groupNam annotations[groupName+apis.CreatorAnnotationSuffix] = ui.Username annotations[groupName+apis.UpdaterAnnotationSuffix] = ui.Username } + objectMetaAccessor.GetObjectMeta().SetAnnotations(annotations) + } +} + +type unstructuredHasSpec struct { + *unstructured.Unstructured +} + +func (us unstructuredHasSpec) GetObjectMeta() metav1.Object { + return us.Unstructured +} + +var _ metav1.ObjectMetaAccessor = unstructuredHasSpec{} + +func (us unstructuredHasSpec) GetUntypedSpec() interface{} { + if s, ok := us.Unstructured.Object["spec"]; ok { + return s + } + return nil +} + +func adaptUnstructuredHasSpecCtx(ctx context.Context, req *admissionv1.AdmissionRequest) context.Context { + if apis.IsInUpdate(ctx) { + b := apis.GetBaseline(ctx) + if apis.IsInStatusUpdate(ctx) { + ctx = apis.WithinSubResourceUpdate(ctx, unstructuredHasSpec{b.(*unstructured.Unstructured)}, req.SubResource) + } else { + ctx = apis.WithinUpdate(ctx, unstructuredHasSpec{b.(*unstructured.Unstructured)}) + } } + return ctx }