From 25e11cd1c143ef136418c33bfbbbd4f24e32e529 Mon Sep 17 00:00:00 2001 From: Joe Betz Date: Fri, 25 Oct 2024 13:46:58 -0400 Subject: [PATCH] Add MutatingAdmissionPolicy plugin to admission chain This expands the generic plugin support to both validating and mutating policies. It also adds the mutating policy admission plugin using the generics plugin support. This also implements both ApplyConfiguration and JSONPatch support. Co-authored-by: Alexander Zielensk --- pkg/kubeapiserver/options/plugins.go | 4 + pkg/kubeapiserver/options/plugins_test.go | 4 +- .../plugin/policy/generic/accessor.go | 1 + .../plugin/policy/generic/interfaces.go | 3 + .../admission/plugin/policy/generic/plugin.go | 12 +- .../policy/generic/policy_dispatcher.go | 157 ++- .../policy/generic/policy_source_test.go | 18 +- .../plugin/policy/mutating/accessor.go | 144 +++ .../plugin/policy/mutating/compilation.go | 81 ++ .../policy/mutating/compilation_test.go | 1045 +++++++++++++++++ .../plugin/policy/mutating/dispatcher.go | 284 +++++ .../plugin/policy/mutating/interface.go | 60 + .../plugin/policy/mutating/patch/interface.go | 43 + .../policy/mutating/patch/json_patch.go | 173 +++ .../plugin/policy/mutating/patch/smd.go | 194 +++ .../policy/mutating/patch/typeconverter.go | 187 +++ .../plugin/policy/mutating/plugin.go | 151 +++ .../plugin/policy/mutating/plugin_test.go | 292 +++++ .../policy/mutating/reinvocationcontext.go | 76 ++ .../plugin/policy/validating/accessor.go | 4 + .../policy/validating/admission_test.go | 3 +- .../plugin/policy/validating/dispatcher.go | 7 +- .../plugin/policy/validating/plugin.go | 10 +- .../plugin/policy/validating/typechecking.go | 2 +- .../apiserver/pkg/server/options/admission.go | 3 +- .../pkg/server/options/admission_test.go | 2 +- .../k8s.io/apiserver/pkg/server/plugins.go | 2 + 27 files changed, 2899 insertions(+), 63 deletions(-) create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/accessor.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/dispatcher.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/interface.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/interface.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/json_patch.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/smd.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/typeconverter.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin_test.go create mode 100644 staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/reinvocationcontext.go diff --git a/pkg/kubeapiserver/options/plugins.go b/pkg/kubeapiserver/options/plugins.go index 7f0bc9ae23c..e9f8c41b814 100644 --- a/pkg/kubeapiserver/options/plugins.go +++ b/pkg/kubeapiserver/options/plugins.go @@ -20,7 +20,9 @@ package options // This should probably be part of some configuration fed into the build for a // given binary target. import ( + mutatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/mutating" validatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/validating" + // Admission policies "k8s.io/kubernetes/plugin/pkg/admission/admit" "k8s.io/kubernetes/plugin/pkg/admission/alwayspullimages" @@ -95,6 +97,7 @@ var AllOrderedPlugins = []string{ // new admission plugins should generally be inserted above here // webhook, resourcequota, and deny plugins must go at the end + mutatingadmissionpolicy.PluginName, // MutatingAdmissionPolicy mutatingwebhook.PluginName, // MutatingAdmissionWebhook validatingadmissionpolicy.PluginName, // ValidatingAdmissionPolicy validatingwebhook.PluginName, // ValidatingAdmissionWebhook @@ -159,6 +162,7 @@ func DefaultOffAdmissionPlugins() sets.Set[string] { certsubjectrestriction.PluginName, // CertificateSubjectRestriction defaultingressclass.PluginName, // DefaultIngressClass podsecurity.PluginName, // PodSecurity + mutatingadmissionpolicy.PluginName, // Mutatingadmissionpolicy, only active when feature gate MutatingAdmissionpolicy is enabled validatingadmissionpolicy.PluginName, // ValidatingAdmissionPolicy, only active when feature gate ValidatingAdmissionPolicy is enabled ) diff --git a/pkg/kubeapiserver/options/plugins_test.go b/pkg/kubeapiserver/options/plugins_test.go index 81784dd75c4..7a3140086ff 100644 --- a/pkg/kubeapiserver/options/plugins_test.go +++ b/pkg/kubeapiserver/options/plugins_test.go @@ -22,9 +22,9 @@ import ( ) func TestAdmissionPluginOrder(t *testing.T) { - // Ensure the last four admission plugins listed are webhooks, quota, and deny + // Sanity check that the order of admission ends with mutating(policy, webhook), validating(policy, webhook), quota, deny. allplugins := strings.Join(AllOrderedPlugins, ",") - expectSuffix := ",MutatingAdmissionWebhook,ValidatingAdmissionPolicy,ValidatingAdmissionWebhook,ResourceQuota,AlwaysDeny" + expectSuffix := ",MutatingAdmissionPolicy,MutatingAdmissionWebhook,ValidatingAdmissionPolicy,ValidatingAdmissionWebhook,ResourceQuota,AlwaysDeny" if !strings.HasSuffix(allplugins, expectSuffix) { t.Fatalf("AllOrderedPlugins must end with ...%s", expectSuffix) } diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/accessor.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/accessor.go index 85b18612f87..515634f0062 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/accessor.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/accessor.go @@ -26,6 +26,7 @@ type PolicyAccessor interface { GetNamespace() string GetParamKind() *v1.ParamKind GetMatchConstraints() *v1.MatchResources + GetFailurePolicy() *v1.FailurePolicyType } type BindingAccessor interface { diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/interfaces.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/interfaces.go index d4dbfb0aa52..58f4a374dca 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/interfaces.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/interfaces.go @@ -49,6 +49,9 @@ type Source[H Hook] interface { // Dispatcher dispatches evaluates an admission request against the currently // active hooks returned by the source. type Dispatcher[H Hook] interface { + // Run the dispatcher. This method should be called only once at startup. + Run(ctx context.Context) error + // Dispatch a request to the policies. Dispatcher may choose not to // call a hook, either because the rules of the hook does not match, or // the namespaceSelector or the objectSelector of the hook does not diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/plugin.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/plugin.go index ed1c621bc8e..fa1f851892b 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/plugin.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/plugin.go @@ -36,8 +36,9 @@ import ( ) // H is the Hook type generated by the source and consumed by the dispatcher. +// !TODO: Just pass in a Plugin[H] with accessors to all this information type sourceFactory[H any] func(informers.SharedInformerFactory, kubernetes.Interface, dynamic.Interface, meta.RESTMapper) Source[H] -type dispatcherFactory[H any] func(authorizer.Authorizer, *matching.Matcher) Dispatcher[H] +type dispatcherFactory[H any] func(authorizer.Authorizer, *matching.Matcher, kubernetes.Interface) Dispatcher[H] // admissionResources is the list of resources related to CEL-based admission // features. @@ -170,7 +171,7 @@ func (c *Plugin[H]) ValidateInitialization() error { } c.source = c.sourceFactory(c.informerFactory, c.client, c.dynamicClient, c.restMapper) - c.dispatcher = c.dispatcherFactory(c.authorizer, c.matcher) + c.dispatcher = c.dispatcherFactory(c.authorizer, c.matcher, c.client) pluginContext, pluginContextCancel := context.WithCancel(context.Background()) go func() { @@ -181,10 +182,15 @@ func (c *Plugin[H]) ValidateInitialization() error { go func() { err := c.source.Run(pluginContext) if err != nil && !errors.Is(err, context.Canceled) { - utilruntime.HandleError(fmt.Errorf("policy source context unexpectedly closed: %v", err)) + utilruntime.HandleError(fmt.Errorf("policy source context unexpectedly closed: %w", err)) } }() + err := c.dispatcher.Run(pluginContext) + if err != nil && !errors.Is(err, context.Canceled) { + utilruntime.HandleError(fmt.Errorf("policy dispatcher context unexpectedly closed: %w", err)) + } + c.SetReadyFunc(func() bool { return namespaceInformer.Informer().HasSynced() && c.source.HasSynced() }) diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_dispatcher.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_dispatcher.go index 62ed7bc6c69..6586f486b94 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_dispatcher.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_dispatcher.go @@ -36,7 +36,7 @@ import ( "k8s.io/client-go/tools/cache" ) -// A policy invocation is a single policy-binding-param tuple from a Policy Hook +// PolicyInvocation is a single policy-binding-param tuple from a Policy Hook // in the context of a specific request. The params have already been resolved // and any error in configuration or setting up the invocation is stored in // the Error field. @@ -62,10 +62,6 @@ type PolicyInvocation[P runtime.Object, B runtime.Object, E Evaluator] struct { // Params fetched by the binding to use to evaluate the policy Param runtime.Object - - // Error is set if there was an error with the policy or binding or its - // params, etc - Error error } // dispatcherDelegate is called during a request with a pre-filtered list @@ -76,7 +72,7 @@ type PolicyInvocation[P runtime.Object, B runtime.Object, E Evaluator] struct { // // The delegate provides the "validation" or "mutation" aspect of dispatcher functionality // (in contrast to generic.PolicyDispatcher which only selects active policies and params) -type dispatcherDelegate[P, B runtime.Object, E Evaluator] func(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces, versionedAttributes webhookgeneric.VersionedAttributeAccessor, invocations []PolicyInvocation[P, B, E]) error +type dispatcherDelegate[P, B runtime.Object, E Evaluator] func(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces, versionedAttributes webhookgeneric.VersionedAttributeAccessor, invocations []PolicyInvocation[P, B, E]) ([]PolicyError, *apierrors.StatusError) type policyDispatcher[P runtime.Object, B runtime.Object, E Evaluator] struct { newPolicyAccessor func(P) PolicyAccessor @@ -104,7 +100,10 @@ func NewPolicyDispatcher[P runtime.Object, B runtime.Object, E Evaluator]( // request. It then resolves all params and creates an Invocation for each // matching policy-binding-param tuple. The delegate is then called with the // list of tuples. -// +func (d *policyDispatcher[P, B, E]) Run(ctx context.Context) error { + return nil +} + // Note: MatchConditions expressions are not evaluated here. The dispatcher delegate // is expected to ignore the result of any policies whose match conditions dont pass. // This may be possible to refactor so matchconditions are checked here instead. @@ -117,29 +116,33 @@ func (d *policyDispatcher[P, B, E]) Dispatch(ctx context.Context, a admission.At objectInterfaces: o, } + var policyErrors []PolicyError + addConfigError := func(err error, definition PolicyAccessor, binding BindingAccessor) { + var message error + if binding == nil { + message = fmt.Errorf("failed to configure policy: %w", err) + } else { + message = fmt.Errorf("failed to configure binding: %w", err) + } + + policyErrors = append(policyErrors, PolicyError{ + Policy: definition, + Binding: binding, + Message: message, + }) + } + for _, hook := range hooks { policyAccessor := d.newPolicyAccessor(hook.Policy) matches, matchGVR, matchGVK, err := d.matcher.DefinitionMatches(a, o, policyAccessor) if err != nil { // There was an error evaluating if this policy matches anything. - utilruntime.HandleError(err) - relevantHooks = append(relevantHooks, PolicyInvocation[P, B, E]{ - Policy: hook.Policy, - Error: err, - }) + addConfigError(err, policyAccessor, nil) continue } else if !matches { continue } else if hook.ConfigurationError != nil { - // The policy matches but there is a configuration error with the - // policy itself - relevantHooks = append(relevantHooks, PolicyInvocation[P, B, E]{ - Policy: hook.Policy, - Error: hook.ConfigurationError, - Resource: matchGVR, - Kind: matchGVK, - }) - utilruntime.HandleError(hook.ConfigurationError) + addConfigError(hook.ConfigurationError, policyAccessor, nil) continue } @@ -148,19 +151,22 @@ func (d *policyDispatcher[P, B, E]) Dispatch(ctx context.Context, a admission.At matches, err = d.matcher.BindingMatches(a, o, bindingAccessor) if err != nil { // There was an error evaluating if this binding matches anything. - utilruntime.HandleError(err) - relevantHooks = append(relevantHooks, PolicyInvocation[P, B, E]{ - Policy: hook.Policy, - Binding: binding, - Error: err, - Resource: matchGVR, - Kind: matchGVK, - }) + addConfigError(err, policyAccessor, bindingAccessor) continue } else if !matches { continue } + // here the binding matches. + // VersionedAttr result will be cached and reused later during parallel + // hook calls. + if _, err = versionedAttrAccessor.VersionedAttribute(matchGVK); err != nil { + // VersionedAttr result will be cached and reused later during parallel + // hook calls. + addConfigError(err, policyAccessor, nil) + continue + } + // Collect params for this binding params, err := CollectParams( policyAccessor.GetParamKind(), @@ -171,14 +177,7 @@ func (d *policyDispatcher[P, B, E]) Dispatch(ctx context.Context, a admission.At ) if err != nil { // There was an error collecting params for this binding. - utilruntime.HandleError(err) - relevantHooks = append(relevantHooks, PolicyInvocation[P, B, E]{ - Policy: hook.Policy, - Binding: binding, - Error: err, - Resource: matchGVR, - Kind: matchGVK, - }) + addConfigError(err, policyAccessor, bindingAccessor) continue } @@ -194,23 +193,72 @@ func (d *policyDispatcher[P, B, E]) Dispatch(ctx context.Context, a admission.At Evaluator: hook.Evaluator, }) } + } + } - // VersionedAttr result will be cached and reused later during parallel - // hook calls - _, err = versionedAttrAccessor.VersionedAttribute(matchGVK) - if err != nil { - return apierrors.NewInternalError(err) - } + if len(relevantHooks) > 0 { + extraPolicyErrors, statusError := d.delegate(ctx, a, o, versionedAttrAccessor, relevantHooks) + if statusError != nil { + return statusError + } + policyErrors = append(policyErrors, extraPolicyErrors...) + } + + var filteredErrors []PolicyError + for _, e := range policyErrors { + // we always default the FailurePolicy if it is unset and validate it in API level + var policy v1.FailurePolicyType + if fp := e.Policy.GetFailurePolicy(); fp == nil { + policy = v1.Fail + } else { + policy = *fp } + switch policy { + case v1.Ignore: + // TODO: add metrics for ignored error here + continue + case v1.Fail: + filteredErrors = append(filteredErrors, e) + default: + filteredErrors = append(filteredErrors, e) + } } - if len(relevantHooks) == 0 { - // no matching hooks - return nil + if len(filteredErrors) > 0 { + + forbiddenErr := admission.NewForbidden(a, fmt.Errorf("admission request denied by policy")) + + // The forbiddenErr is always a StatusError. + var err *apierrors.StatusError + if !errors.As(forbiddenErr, &err) { + // Should never happen. + return apierrors.NewInternalError(fmt.Errorf("failed to create status error")) + } + err.ErrStatus.Message = "" + + for _, policyError := range filteredErrors { + message := policyError.Error() + + // If this is the first denied decision, use its message and reason + // for the status error message. + if err.ErrStatus.Message == "" { + err.ErrStatus.Message = message + if policyError.Reason != "" { + err.ErrStatus.Reason = policyError.Reason + } + } + + // Add the denied decision's message to the status error's details + err.ErrStatus.Details.Causes = append( + err.ErrStatus.Details.Causes, + metav1.StatusCause{Message: message}) + } + + return err } - return d.delegate(ctx, a, o, versionedAttrAccessor, relevantHooks) + return nil } // Returns params to use to evaluate a policy-binding with given param @@ -352,3 +400,18 @@ func (v *versionedAttributeAccessor) VersionedAttribute(gvk schema.GroupVersionK v.versionedAttrs[gvk] = versionedAttr return versionedAttr, nil } + +type PolicyError struct { + Policy PolicyAccessor + Binding BindingAccessor + Message error + Reason metav1.StatusReason +} + +func (c PolicyError) Error() string { + if c.Binding != nil { + return fmt.Sprintf("policy '%s' with binding '%s' denied request: %s", c.Policy.GetName(), c.Binding.GetName(), c.Message.Error()) + } + + return fmt.Sprintf("policy '%s' denied request: %s", c.Policy.GetName(), c.Message.Error()) +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_source_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_source_test.go index 218b0861100..6f95ae25715 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_source_test.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/generic/policy_source_test.go @@ -17,6 +17,7 @@ limitations under the License. package generic_test import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -24,15 +25,26 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/plugin/policy/generic" "k8s.io/apiserver/pkg/admission/plugin/policy/matching" "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/cache" ) -func makeTestDispatcher(authorizer.Authorizer, *matching.Matcher) generic.Dispatcher[generic.PolicyHook[*FakePolicy, *FakeBinding, generic.Evaluator]] { +type fakeDispatcher struct{} + +func (fd *fakeDispatcher) Dispatch(context.Context, admission.Attributes, admission.ObjectInterfaces, []generic.PolicyHook[*FakePolicy, *FakeBinding, generic.Evaluator]) error { return nil } +func (fd *fakeDispatcher) Run(context.Context) error { + return nil +} + +func makeTestDispatcher(authorizer.Authorizer, *matching.Matcher, kubernetes.Interface) generic.Dispatcher[generic.PolicyHook[*FakePolicy, *FakeBinding, generic.Evaluator]] { + return &fakeDispatcher{} +} func TestPolicySourceHasSyncedEmpty(t *testing.T) { testContext, testCancel, err := generic.NewPolicyTestContext( @@ -207,6 +219,10 @@ func (fb *FakePolicy) GetMatchConstraints() *v1.MatchResources { return nil } +func (fb *FakePolicy) GetFailurePolicy() *v1.FailurePolicyType { + return nil +} + func (fb *FakeBinding) GetName() string { return fb.Name } diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/accessor.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/accessor.go new file mode 100644 index 00000000000..e5ef242fa37 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/accessor.go @@ -0,0 +1,144 @@ +/* +Copyright 2024 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 ( + v1 "k8s.io/api/admissionregistration/v1" + "k8s.io/api/admissionregistration/v1alpha1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/admission/plugin/policy/generic" +) + +func NewMutatingAdmissionPolicyAccessor(obj *Policy) generic.PolicyAccessor { + return &mutatingAdmissionPolicyAccessor{ + Policy: obj, + } +} + +func NewMutatingAdmissionPolicyBindingAccessor(obj *PolicyBinding) generic.BindingAccessor { + return &mutatingAdmissionPolicyBindingAccessor{ + PolicyBinding: obj, + } +} + +type mutatingAdmissionPolicyAccessor struct { + *Policy +} + +func (v *mutatingAdmissionPolicyAccessor) GetNamespace() string { + return v.Namespace +} + +func (v *mutatingAdmissionPolicyAccessor) GetName() string { + return v.Name +} + +func (v *mutatingAdmissionPolicyAccessor) GetParamKind() *v1.ParamKind { + pk := v.Spec.ParamKind + if pk == nil { + return nil + } + return &v1.ParamKind{ + APIVersion: pk.APIVersion, + Kind: pk.Kind, + } +} + +func (v *mutatingAdmissionPolicyAccessor) GetMatchConstraints() *v1.MatchResources { + return convertV1alpha1ResourceRulesToV1(v.Spec.MatchConstraints) +} + +func (v *mutatingAdmissionPolicyAccessor) GetFailurePolicy() *v1.FailurePolicyType { + return toV1FailurePolicy(v.Spec.FailurePolicy) +} + +func toV1FailurePolicy(failurePolicy *v1alpha1.FailurePolicyType) *v1.FailurePolicyType { + if failurePolicy == nil { + return nil + } + fp := v1.FailurePolicyType(*failurePolicy) + return &fp +} + +type mutatingAdmissionPolicyBindingAccessor struct { + *PolicyBinding +} + +func (v *mutatingAdmissionPolicyBindingAccessor) GetNamespace() string { + return v.Namespace +} + +func (v *mutatingAdmissionPolicyBindingAccessor) GetName() string { + return v.Name +} + +func (v *mutatingAdmissionPolicyBindingAccessor) GetPolicyName() types.NamespacedName { + return types.NamespacedName{ + Namespace: "", + Name: v.Spec.PolicyName, + } +} + +func (v *mutatingAdmissionPolicyBindingAccessor) GetMatchResources() *v1.MatchResources { + return convertV1alpha1ResourceRulesToV1(v.Spec.MatchResources) +} + +func (v *mutatingAdmissionPolicyBindingAccessor) GetParamRef() *v1.ParamRef { + if v.Spec.ParamRef == nil { + return nil + } + + var nfa *v1.ParameterNotFoundActionType + if v.Spec.ParamRef.ParameterNotFoundAction != nil { + nfa = new(v1.ParameterNotFoundActionType) + *nfa = v1.ParameterNotFoundActionType(*v.Spec.ParamRef.ParameterNotFoundAction) + } + + return &v1.ParamRef{ + Name: v.Spec.ParamRef.Name, + Namespace: v.Spec.ParamRef.Namespace, + Selector: v.Spec.ParamRef.Selector, + ParameterNotFoundAction: nfa, + } +} + +func convertV1alpha1ResourceRulesToV1(mc *v1alpha1.MatchResources) *v1.MatchResources { + if mc == nil { + return nil + } + + var res v1.MatchResources + res.NamespaceSelector = mc.NamespaceSelector + res.ObjectSelector = mc.ObjectSelector + for _, ex := range mc.ExcludeResourceRules { + res.ExcludeResourceRules = append(res.ExcludeResourceRules, v1.NamedRuleWithOperations{ + ResourceNames: ex.ResourceNames, + RuleWithOperations: ex.RuleWithOperations, + }) + } + for _, ex := range mc.ResourceRules { + res.ResourceRules = append(res.ResourceRules, v1.NamedRuleWithOperations{ + ResourceNames: ex.ResourceNames, + RuleWithOperations: ex.RuleWithOperations, + }) + } + if mc.MatchPolicy != nil { + mp := v1.MatchPolicyType(*mc.MatchPolicy) + res.MatchPolicy = &mp + } + return &res +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation.go new file mode 100644 index 00000000000..54d5938dd62 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation.go @@ -0,0 +1,81 @@ +/* +Copyright 2024 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" + + "k8s.io/api/admissionregistration/v1alpha1" + plugincel "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch" + "k8s.io/apiserver/pkg/admission/plugin/webhook/matchconditions" + apiservercel "k8s.io/apiserver/pkg/cel" + "k8s.io/apiserver/pkg/cel/environment" +) + +// compilePolicy compiles the policy into a PolicyEvaluator +// any error is stored and delayed until invocation. +// +// Each individual mutation is compiled into MutationEvaluationFunc and +// returned is a PolicyEvaluator in the same order as the mutations appeared in the policy. +func compilePolicy(policy *Policy) PolicyEvaluator { + opts := plugincel.OptionalVariableDeclarations{HasParams: policy.Spec.ParamKind != nil, StrictCost: true, HasAuthorizer: true} + compiler, err := plugincel.NewCompositedCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion(), true)) + if err != nil { + return PolicyEvaluator{Error: &apiservercel.Error{ + Type: apiservercel.ErrorTypeInternal, + Detail: fmt.Sprintf("failed to initialize CEL compiler: %v", err), + }} + } + + // Compile and store variables + compiler.CompileAndStoreVariables(convertv1alpha1Variables(policy.Spec.Variables), opts, environment.StoredExpressions) + + // Compile matchers + var matcher matchconditions.Matcher = nil + matchConditions := policy.Spec.MatchConditions + if len(matchConditions) > 0 { + matchExpressionAccessors := make([]plugincel.ExpressionAccessor, len(matchConditions)) + for i := range matchConditions { + matchExpressionAccessors[i] = (*matchconditions.MatchCondition)(&matchConditions[i]) + } + matcher = matchconditions.NewMatcher(compiler.CompileCondition(matchExpressionAccessors, opts, environment.StoredExpressions), toV1FailurePolicy(policy.Spec.FailurePolicy), "policy", "validate", policy.Name) + } + + // Compiler patchers + var patchers []patch.Patcher + patchOptions := opts + patchOptions.HasPatchTypes = true + for _, m := range policy.Spec.Mutations { + switch m.PatchType { + case v1alpha1.PatchTypeJSONPatch: + if m.JSONPatch != nil { + accessor := &JSONPatchCondition{Expression: m.JSONPatch.Expression} + compileResult := compiler.CompileMutatingEvaluator(accessor, patchOptions, environment.StoredExpressions) + patchers = append(patchers, patch.NewJSONPatcher(compileResult)) + } + case v1alpha1.PatchTypeApplyConfiguration: + if m.ApplyConfiguration != nil { + accessor := &ApplyConfigurationCondition{Expression: m.ApplyConfiguration.Expression} + compileResult := compiler.CompileMutatingEvaluator(accessor, patchOptions, environment.StoredExpressions) + patchers = append(patchers, patch.NewApplyConfigurationPatcher(compileResult)) + } + } + } + + return PolicyEvaluator{Matcher: matcher, Mutators: patchers, CompositionEnv: compiler.CompositionEnv} +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation_test.go new file mode 100644 index 00000000000..c9de4a343eb --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/compilation_test.go @@ -0,0 +1,1045 @@ +/* +Copyright 2024 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" + "github.com/google/go-cmp/cmp" + "strings" + "testing" + + "k8s.io/api/admissionregistration/v1alpha1" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch" + celconfig "k8s.io/apiserver/pkg/apis/cel" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/openapi/openapitest" + "k8s.io/utils/ptr" +) + +// TestCompilation is an open-box test of mutatingEvaluator.compile +// However, the result is a set of CEL programs, manually invoke them to assert +// on the results. +func TestCompilation(t *testing.T) { + deploymentGVR := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} + testCases := []struct { + name string + policy *Policy + gvr schema.GroupVersionResource + object runtime.Object + oldObject runtime.Object + params runtime.Object + namespace *corev1.Namespace + expectedErr string + expectedResult runtime.Object + }{ + { + name: "jsonPatch with false test operation", + policy: jsonPatches(policy("d1"), + v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "test", path: "/spec/replicas", value: 100}, + JSONPatch{op: "replace", path: "/spec/replicas", value: 3}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + }, + { + name: "jsonPatch with true test operation", + policy: jsonPatches(policy("d1"), + v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "test", path: "/spec/replicas", value: 1}, + JSONPatch{op: "replace", path: "/spec/replicas", value: 3}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](3)}}, + }, + { + name: "jsonPatch remove to unset field", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "remove", path: "/spec/replicas"}, + ]`, + }), + + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch remove map entry by key", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "remove", path: "/metadata/labels/y"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1", "y": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch remove element in list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "remove", path: "/spec/template/spec/containers/1"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "b"}, {Name: "c"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "c"}}, + }}}}, + }, + { + name: "jsonPatch copy map entry by key", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "copy", from: "/metadata/labels/x", path: "/metadata/labels/y"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1", "y": "1"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch copy first element to end of list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "copy", from: "/spec/template/spec/containers/0", path: "/spec/template/spec/containers/-"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "b"}, {Name: "c"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "b"}, {Name: "c"}, {Name: "a"}}, + }}}}, + }, + { + name: "jsonPatch move map entry by key", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "move", from: "/metadata/labels/x", path: "/metadata/labels/y"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "1"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch move first element to end of list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "move", from: "/spec/template/spec/containers/0", path: "/spec/template/spec/containers/-"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "b"}, {Name: "c"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "b"}, {Name: "c"}, {Name: "a"}}, + }}}}, + }, + { + name: "jsonPatch add map entry by key and value", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/metadata/labels/x", value: "2"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "1", "x": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch add map value to field", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/metadata/labels", value: {"y": "2"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch add map to existing map", // performs a replacement + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/metadata/labels", value: {"y": "2"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch add to start of list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/spec/template/spec/containers/0", value: {"name": "x"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "x"}, {Name: "a"}}, + }}}}, + }, + { + name: "jsonPatch add to end of list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/spec/template/spec/containers/-", value: {"name": "x"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "x"}}, + }}}}, + }, + { + name: "jsonPatch replace key in map", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/metadata/labels/x", value: "2"}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "1", "x": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch replace map value of unset field", // adds the field value + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/metadata/labels", value: {"y": "2"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch replace map value of set field", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/metadata/labels", value: {"y": "2"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"x": "1"}}, Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"y": "2"}}, Spec: appsv1.DeploymentSpec{}}, + }, + { + name: "jsonPatch replace first element in list", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/template/spec/containers/0", value: {"name": "x"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "x"}}, + }}}}, + }, + { + name: "jsonPatch replace end of list with - not allowed", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/template/spec/containers/-", value: {"name": "x"}}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedErr: "JSON Patch: replace operation does not apply: doc is missing key: /spec/template/spec/containers/-: missing value", + }, + { + name: "jsonPatch replace with variable", + policy: jsonPatches(variables(policy("d1"), v1alpha1.Variable{Name: "desired", Expression: "10"}), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", value: variables.desired + 1}, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](11)}}, + }, + { + name: "jsonPatch with CEL initializer", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/spec/template/spec/containers/-", value: Object.spec.template.spec.containers{ + name: "x", + ports: [Object.spec.template.spec.containers.ports{containerPort: 8080}], + } + }, + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}, {Name: "x", Ports: []corev1.ContainerPort{{ContainerPort: 8080}}}}, + }}}}, + }, + { + name: "jsonPatch invalid CEL initializer field", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/spec/template/spec/containers/-", + value: Object.spec.template.spec.containers{ + name: "x", + ports: [Object.spec.template.spec.containers.ports{containerPortZ: 8080}] + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedErr: "strict decoding error: unknown field \"spec.template.spec.containers[1].ports[0].containerPortZ\"", + }, + { + name: "jsonPatch invalid CEL initializer type", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/spec/template/spec/containers/-", + value: Object.spec.template.spec.containers{ + name: "x", + ports: [Object.spec.template.spec.containers.portsZ{containerPort: 8080}] + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "a"}}, + }}}}, + expectedErr: " mismatch: unexpected type name \"Object.spec.template.spec.containers.portsZ\", expected \"Object.spec.template.spec.containers.ports\", which matches field name path from root Object type", + }, + { + name: "jsonPatch add map entry by key and value", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "add", path: "/spec", value: Object.spec{selector: Object.spec.selector{}, replicas: 10}} + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Selector: &metav1.LabelSelector{}, Replicas: ptr.To[int32](10)}}, + }, + { + name: "JSONPatch patch type has field access", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "op": JSONPatch{op: "opValue"}.op, + "path": JSONPatch{path: "pathValue"}.path, + "from": JSONPatch{from: "fromValue"}.from, + "value": string(JSONPatch{value: "valueValue"}.value), + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "op": "opValue", + "path": "pathValue", + "from": "fromValue", + "value": "valueValue", + }}}, + }, + { + name: "JSONPatch patch type has field testing", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "op": string(has(JSONPatch{op: "opValue"}.op)), + "path": string(has(JSONPatch{path: "pathValue"}.path)), + "from": string(has(JSONPatch{from: "fromValue"}.from)), + "value": string(has(JSONPatch{value: "valueValue"}.value)), + "op-unset": string(has(JSONPatch{}.op)), + "path-unset": string(has(JSONPatch{}.path)), + "from-unset": string(has(JSONPatch{}.from)), + "value-unset": string(has(JSONPatch{}.value)), + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "op": "true", + "path": "true", + "from": "true", + "value": "true", + "op-unset": "false", + "path-unset": "false", + "from-unset": "false", + "value-unset": "false", + }}}, + }, + { + name: "JSONPatch patch type equality", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "empty": string(JSONPatch{} == JSONPatch{}), + "partial": string(JSONPatch{op: "add"} == JSONPatch{op: "add"}), + "same-all": string(JSONPatch{op: "add", path: "path", from: "from", value: 1} == JSONPatch{op: "add", path: "path", from: "from", value: 1}), + "different-op": string(JSONPatch{op: "add"} == JSONPatch{op: "remove"}), + "different-path": string(JSONPatch{op: "add", path: "x", from: "from", value: 1} == JSONPatch{op: "add", path: "path", from: "from", value: 1}), + "different-from": string(JSONPatch{op: "add", path: "path", from: "x", value: 1} == JSONPatch{op: "add", path: "path", from: "from", value: 1}), + "different-value": string(JSONPatch{op: "add", path: "path", from: "from", value: "1"} == JSONPatch{op: "add", path: "path", from: "from", value: 1}), + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "empty": "true", + "partial": "true", + "same-all": "true", + "different-op": "false", + "different-path": "false", + "different-from": "false", + "different-value": "false", + }}}, + }, + { + name: "JSONPatch key escaping", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", value: {} + }, + JSONPatch{ + op: "add", path: "/metadata/labels/" + jsonpatch.escapeKey("k8s.io/x~y"), value: "true" + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "k8s.io/x~y": "true", + }}}, + }, + { + name: "applyConfiguration then jsonPatch", + policy: mutations(policy("d1"), v1alpha1.Mutation{ + PatchType: v1alpha1.PatchTypeApplyConfiguration, + ApplyConfiguration: &v1alpha1.ApplyConfiguration{ + Expression: `Object{ + spec: Object.spec{ + replicas: object.spec.replicas + 100 + } + }`, + }, + }, + v1alpha1.Mutation{ + PatchType: v1alpha1.PatchTypeJSONPatch, + JSONPatch: &v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", value: object.spec.replicas + 10} + ]`, + }, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](111)}}, + }, + { + name: "jsonPatch then applyConfiguration", + policy: mutations(policy("d1"), + v1alpha1.Mutation{ + PatchType: v1alpha1.PatchTypeJSONPatch, + JSONPatch: &v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", value: object.spec.replicas + 10} + ]`, + }, + }, + v1alpha1.Mutation{ + PatchType: v1alpha1.PatchTypeApplyConfiguration, + ApplyConfiguration: &v1alpha1.ApplyConfiguration{ + Expression: `Object{ + spec: Object.spec{ + replicas: object.spec.replicas + 100 + } + }`, + }, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](111)}}, + }, + { + name: "apply configuration add to listType=map", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + template: Object.spec.template{ + spec: Object.spec.template.spec{ + volumes: [Object.spec.template.spec.volumes{ + name: "y" + }] + } + } + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{{Name: "x"}}, + }, + }, + }}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{{Name: "x"}, {Name: "y"}}, + }, + }, + }}, + }, + { + name: "apply configuration update listType=map entry", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + template: Object.spec.template{ + spec: Object.spec.template.spec{ + volumes: [Object.spec.template.spec.volumes{ + name: "y", + hostPath: Object.spec.template.spec.volumes.hostPath{ + path: "a" + } + }] + } + } + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{{Name: "x"}, {Name: "y"}}, + }, + }, + }}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{{Name: "x"}, {Name: "y", VolumeSource: corev1.VolumeSource{HostPath: &corev1.HostPathVolumeSource{Path: "a"}}}}, + }, + }, + }}, + }, + { + name: "apply configuration with conditionals", + policy: applyConfigurations(policy("d1"), ` + Object{ + spec: Object.spec{ + replicas: object.spec.replicas % 2 == 0?object.spec.replicas + 1:object.spec.replicas + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](2)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](3)}}, + }, + { + name: "apply configuration with old object", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + replicas: oldObject.spec.replicas % 2 == 0?oldObject.spec.replicas + 1:oldObject.spec.replicas + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + oldObject: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](2)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](3)}}, + }, + { + name: "apply configuration with variable", + policy: applyConfigurations(variables(policy("d1"), v1alpha1.Variable{Name: "desired", Expression: "10"}), + `Object{ + spec: Object.spec{ + replicas: variables.desired + 1 + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](11)}}, + }, + { + name: "apply configuration with params", + policy: paramKind(applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + replicas: int(params.data['k1']) + } + }`), &v1alpha1.ParamKind{Kind: "ConfigMap", APIVersion: "v1"}), + params: &corev1.ConfigMap{Data: map[string]string{"k1": "100"}}, + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](100)}}, + }, + { + name: "complex apply configuration initialization", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + replicas: 1, + template: Object.spec.template{ + metadata: Object.spec.template.metadata{ + labels: {"app": "nginx"} + }, + spec: Object.spec.template.spec{ + containers: [Object.spec.template.spec.containers{ + name: "nginx", + image: "nginx:1.14.2", + ports: [Object.spec.template.spec.containers.ports{ + containerPort: 80 + }], + resources: Object.spec.template.spec.containers.resources{ + limits: {"cpu": "128M"}, + } + }] + } + } + } + }`), + + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "nginx"}, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{ + Name: "nginx", + Image: "nginx:1.14.2", + Ports: []corev1.ContainerPort{ + {ContainerPort: 80}, + }, + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{corev1.ResourceName("cpu"): resource.MustParse("128M")}, + }, + }}, + }, + }, + }}, + }, + { + name: "apply configuration with invalid type name", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.specx{ + replicas: 1 + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "type mismatch: unexpected type name \"Object.specx\", expected \"Object.spec\", which matches field name path from root Object type", + }, + { + name: "apply configuration with invalid field name", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + replicasx: 1 + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "error applying patch: failed to convert patch object to typed object: .spec.replicasx: field not declared in schema", + }, + { + name: "apply configuration with invalid return type", + policy: applyConfigurations(policy("d1"), + `"I'm a teapot!"`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "must evaluate to Object but got string", + }, + { + name: "apply configuration with invalid initializer return type", + policy: applyConfigurations(policy("d1"), + `Object.spec.metadata{}`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "must evaluate to Object but got Object.spec.metadata", + }, + { + name: "jsonPatch with excessive cost", + policy: jsonPatches(variables(policy("d1"), v1alpha1.Variable{Name: "list", Expression: "[0,1,2,3,4,5,6,7,8,9]"}), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", + value: variables.list.all(x1, variables.list.all(x2, variables.list.all(x3, variables.list.all(x4, variables.list.all(x5, variables.list.all(x5, "0123456789" == "0123456789"))))))? 1 : 0 + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "operation cancelled: actual cost limit exceeded", + }, + { + name: "applyConfiguration with excessive cost", + policy: variables(applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + replicas: variables.list.all(x1, variables.list.all(x2, variables.list.all(x3, variables.list.all(x4, variables.list.all(x5, variables.list.all(x5, "0123456789" == "0123456789"))))))? 1 : 0 + } + }`), v1alpha1.Variable{Name: "list", Expression: "[0,1,2,3,4,5,6,7,8,9]"}), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "operation cancelled: actual cost limit exceeded", + }, + { + name: "request variable", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", + value: request.kind.group == 'apps' && request.kind.version == 'v1' && request.kind.kind == 'Deployment' ? 10 : 0 + } + ]`}), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](10)}}, + }, + { + name: "namespace request variable", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", + value: namespaceObject.metadata.name == 'ns1' ? 10 : 0 + } + ]`}), + namespace: &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "ns1"}}, + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](10)}}, + }, + { + name: "authorizer check", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{op: "replace", path: "/spec/replicas", + value: authorizer.group('').resource('endpoints').check('create').allowed() ? 10 : 0 + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedResult: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](10)}}, + }, + { + name: "apply configuration with change to atomic", + policy: applyConfigurations(policy("d1"), + `Object{ + spec: Object.spec{ + selector: Object.spec.selector{ + matchLabels: {"l": "v"} + } + } + }`), + gvr: deploymentGVR, + object: &appsv1.Deployment{Spec: appsv1.DeploymentSpec{Replicas: ptr.To[int32](1)}}, + expectedErr: "error applying patch: invalid ApplyConfiguration: may not mutate atomic arrays, maps or structs: .spec.selector", + }, + { + name: "object type has field access", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "value": Object{field: "fieldValue"}.field, + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "value": "fieldValue", + }}}, + }, + { + name: "object type has field testing", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "field": string(has(Object{field: "fieldValue"}.field)), + "field-unset": string(has(Object{}.field)), + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "field": "true", + "field-unset": "false", + }}}, + }, + { + name: "object type equality", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/metadata/labels", + value: { + "empty": string(Object{} == Object{}), + "same": string(Object{field: "x"} == Object{field: "x"}), + "different": string(Object{field: "x"} == Object{field: "y"}), + } + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{ + "empty": "true", + "same": "true", + "different": "false", + }}}, + }, + { + // TODO: This test documents existing behavior that we should be fixed before + // MutatingAdmissionPolicy graduates to beta. + // It is possible to initialize invalid Object types because we do not yet perform + // a full compilation pass with the types fully bound. Before beta, we should + // recompile all expressions with fully bound types before evaluation and report + // errors if invalid Object types like this are initialized. + name: "object types are not fully type checked", + policy: jsonPatches(policy("d1"), v1alpha1.JSONPatch{ + Expression: `[ + JSONPatch{ + op: "add", path: "/spec", + value: Object.invalid{replicas: 1} + } + ]`, + }), + gvr: deploymentGVR, + object: &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}}, + expectedResult: &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{}}, + Spec: appsv1.DeploymentSpec{ + Replicas: ptr.To[int32](1), + }, + }, + }, + } + + scheme := runtime.NewScheme() + err := appsv1.AddToScheme(scheme) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + tcManager := patch.NewTypeConverterManager(nil, openapitest.NewEmbeddedFileClient()) + go tcManager.Run(ctx) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var gvk schema.GroupVersionKind + gvks, _, err := scheme.ObjectKinds(tc.object) + if err != nil { + t.Fatal(err) + } + if len(gvks) == 1 { + gvk = gvks[0] + } else { + t.Fatalf("Failed to find gvk for type: %T", tc.object) + } + + policyEvaluator := compilePolicy(tc.policy) + if policyEvaluator.CompositionEnv != nil { + ctx = policyEvaluator.CompositionEnv.CreateContext(ctx) + } + obj := tc.object + + typeAccessor, err := meta.TypeAccessor(obj) + if err != nil { + t.Fatal(err) + } + typeAccessor.SetKind(gvk.Kind) + typeAccessor.SetAPIVersion(gvk.GroupVersion().String()) + typeConverter := tcManager.GetTypeConverter(gvk) + + metaAccessor, err := meta.Accessor(obj) + if err != nil { + t.Fatal(err) + } + + for _, patcher := range policyEvaluator.Mutators { + attrs := admission.NewAttributesRecord(obj, tc.oldObject, gvk, + metaAccessor.GetName(), metaAccessor.GetNamespace(), tc.gvr, + "", admission.Create, &metav1.CreateOptions{}, false, nil) + vAttrs := &admission.VersionedAttributes{ + Attributes: attrs, + VersionedKind: gvk, + VersionedObject: obj, + VersionedOldObject: tc.oldObject, + } + r := patch.Request{ + MatchedResource: tc.gvr, + VersionedAttributes: vAttrs, + ObjectInterfaces: admission.NewObjectInterfacesFromScheme(scheme), + OptionalVariables: cel.OptionalVariableBindings{VersionedParams: tc.params, Authorizer: fakeAuthorizer{}}, + Namespace: tc.namespace, + TypeConverter: typeConverter, + } + obj, err = patcher.Patch(ctx, r, celconfig.RuntimeCELCostBudget) + if len(tc.expectedErr) > 0 { + if err == nil { + t.Fatalf("expected error: %s", tc.expectedErr) + } else { + if !strings.Contains(err.Error(), tc.expectedErr) { + t.Fatalf("expected error: %s, got: %s", tc.expectedErr, err.Error()) + } + return + } + } + if err != nil && len(tc.expectedErr) == 0 { + t.Fatalf("unexpected error: %v", err) + } + } + got, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + t.Fatal(err) + } + + wantTypeAccessor, err := meta.TypeAccessor(tc.expectedResult) + if err != nil { + t.Fatal(err) + } + wantTypeAccessor.SetKind(gvk.Kind) + wantTypeAccessor.SetAPIVersion(gvk.GroupVersion().String()) + + want, err := runtime.DefaultUnstructuredConverter.ToUnstructured(tc.expectedResult) + if err != nil { + t.Fatal(err) + } + if !equality.Semantic.DeepEqual(want, got) { + t.Errorf("unexpected result, got diff:\n%s\n", cmp.Diff(want, got)) + } + }) + } +} + +func policy(name string) *v1alpha1.MutatingAdmissionPolicy { + return &v1alpha1.MutatingAdmissionPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: v1alpha1.MutatingAdmissionPolicySpec{}, + } +} + +func variables(policy *v1alpha1.MutatingAdmissionPolicy, variables ...v1alpha1.Variable) *v1alpha1.MutatingAdmissionPolicy { + policy.Spec.Variables = append(policy.Spec.Variables, variables...) + return policy +} + +func jsonPatches(policy *v1alpha1.MutatingAdmissionPolicy, jsonPatches ...v1alpha1.JSONPatch) *v1alpha1.MutatingAdmissionPolicy { + for _, jsonPatch := range jsonPatches { + policy.Spec.Mutations = append(policy.Spec.Mutations, v1alpha1.Mutation{ + JSONPatch: &jsonPatch, + PatchType: v1alpha1.PatchTypeJSONPatch, + }) + } + + return policy +} + +func applyConfigurations(policy *v1alpha1.MutatingAdmissionPolicy, expressions ...string) *v1alpha1.MutatingAdmissionPolicy { + for _, expression := range expressions { + policy.Spec.Mutations = append(policy.Spec.Mutations, v1alpha1.Mutation{ + ApplyConfiguration: &v1alpha1.ApplyConfiguration{Expression: expression}, + PatchType: v1alpha1.PatchTypeApplyConfiguration, + }) + } + return policy +} + +func paramKind(policy *v1alpha1.MutatingAdmissionPolicy, paramKind *v1alpha1.ParamKind) *v1alpha1.MutatingAdmissionPolicy { + policy.Spec.ParamKind = paramKind + return policy +} + +func mutations(policy *v1alpha1.MutatingAdmissionPolicy, mutations ...v1alpha1.Mutation) *v1alpha1.MutatingAdmissionPolicy { + policy.Spec.Mutations = append(policy.Spec.Mutations, mutations...) + return policy +} + +type fakeAuthorizer struct{} + +func (f fakeAuthorizer) Authorize(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { + return authorizer.DecisionAllow, "", nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/dispatcher.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/dispatcher.go new file mode 100644 index 00000000000..0d93661c863 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/dispatcher.go @@ -0,0 +1,284 @@ +/* +Copyright 2024 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" + "errors" + "fmt" + + "k8s.io/api/admissionregistration/v1alpha1" + v1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apiserver/pkg/admission" + admissionauthorizer "k8s.io/apiserver/pkg/admission/plugin/authorizer" + "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/admission/plugin/policy/generic" + "k8s.io/apiserver/pkg/admission/plugin/policy/matching" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch" + webhookgeneric "k8s.io/apiserver/pkg/admission/plugin/webhook/generic" + celconfig "k8s.io/apiserver/pkg/apis/cel" + "k8s.io/apiserver/pkg/authorization/authorizer" +) + +func NewDispatcher(a authorizer.Authorizer, m *matching.Matcher, tcm patch.TypeConverterManager) generic.Dispatcher[PolicyHook] { + res := &dispatcher{ + matcher: m, + authz: a, + //!TODO: pass in static type converter to reduce network calls + typeConverterManager: tcm, + } + res.Dispatcher = generic.NewPolicyDispatcher[*Policy, *PolicyBinding, PolicyEvaluator]( + NewMutatingAdmissionPolicyAccessor, + NewMutatingAdmissionPolicyBindingAccessor, + m, + res.dispatchInvocations, + ) + return res +} + +type dispatcher struct { + matcher *matching.Matcher + authz authorizer.Authorizer + typeConverterManager patch.TypeConverterManager + generic.Dispatcher[PolicyHook] +} + +func (d *dispatcher) Run(ctx context.Context) error { + go d.typeConverterManager.Run(ctx) + return d.Dispatcher.Run(ctx) +} + +func (d *dispatcher) dispatchInvocations( + ctx context.Context, + a admission.Attributes, + o admission.ObjectInterfaces, + versionedAttributes webhookgeneric.VersionedAttributeAccessor, + invocations []generic.PolicyInvocation[*Policy, *PolicyBinding, PolicyEvaluator], +) ([]generic.PolicyError, *k8serrors.StatusError) { + var lastVersionedAttr *admission.VersionedAttributes + + reinvokeCtx := a.GetReinvocationContext() + var policyReinvokeCtx *policyReinvokeContext + if v := reinvokeCtx.Value(PluginName); v != nil { + policyReinvokeCtx = v.(*policyReinvokeContext) + } else { + policyReinvokeCtx = &policyReinvokeContext{} + reinvokeCtx.SetValue(PluginName, policyReinvokeCtx) + } + + if reinvokeCtx.IsReinvoke() && policyReinvokeCtx.IsOutputChangedSinceLastPolicyInvocation(a.GetObject()) { + // If the object has changed, we know the in-tree plugin re-invocations have mutated the object, + // and we need to reinvoke all eligible policies. + policyReinvokeCtx.RequireReinvokingPreviouslyInvokedPlugins() + } + defer func() { + policyReinvokeCtx.SetLastPolicyInvocationOutput(a.GetObject()) + }() + + var policyErrors []generic.PolicyError + addConfigError := func(err error, invocation generic.PolicyInvocation[*Policy, *PolicyBinding, PolicyEvaluator], reason metav1.StatusReason) { + policyErrors = append(policyErrors, generic.PolicyError{ + Message: err, + Policy: NewMutatingAdmissionPolicyAccessor(invocation.Policy), + Binding: NewMutatingAdmissionPolicyBindingAccessor(invocation.Binding), + Reason: reason, + }) + } + + // There is at least one invocation to invoke. Make sure we have a namespace + // object if the incoming object is not cluster scoped to pass into the evaluator. + var namespace *v1.Namespace + var err error + namespaceName := a.GetNamespace() + + // Special case, the namespace object has the namespace of itself (maybe a bug). + // unset it if the incoming object is a namespace + if gvk := a.GetKind(); gvk.Kind == "Namespace" && gvk.Version == "v1" && gvk.Group == "" { + namespaceName = "" + } + + // if it is cluster scoped, namespaceName will be empty + // Otherwise, get the Namespace resource. + if namespaceName != "" { + namespace, err = d.matcher.GetNamespace(namespaceName) + if err != nil { + return nil, k8serrors.NewNotFound(schema.GroupResource{Group: "", Resource: "namespaces"}, namespaceName) + } + } + + authz := admissionauthorizer.NewCachingAuthorizer(d.authz) + + // Should loop through invocations, handling possible error and invoking + // evaluator to apply patch, also should handle re-invocations + for _, invocation := range invocations { + if invocation.Evaluator.CompositionEnv != nil { + ctx = invocation.Evaluator.CompositionEnv.CreateContext(ctx) + } + if len(invocation.Evaluator.Mutators) != len(invocation.Policy.Spec.Mutations) { + // This would be a bug. The compiler should always return exactly as + // many evaluators as there are mutations + return nil, k8serrors.NewInternalError(fmt.Errorf("expected %v compiled evaluators for policy %v, got %v", + invocation.Policy.Name, len(invocation.Policy.Spec.Mutations), len(invocation.Evaluator.Mutators))) + } + + versionedAttr, err := versionedAttributes.VersionedAttribute(invocation.Kind) + if err != nil { + // This should never happen, we pre-warm versoined attribute + // accessors before starting the dispatcher + return nil, k8serrors.NewInternalError(err) + } + + if invocation.Evaluator.Matcher != nil { + matchResults := invocation.Evaluator.Matcher.Match(ctx, versionedAttr, invocation.Param, authz) + if matchResults.Error != nil { + addConfigError(matchResults.Error, invocation, metav1.StatusReasonInvalid) + } + + // if preconditions are not met, then skip mutations + if !matchResults.Matches { + continue + } + } + + invocationKey, invocationKeyErr := keyFor(invocation) + if reinvokeCtx.IsReinvoke() && !policyReinvokeCtx.ShouldReinvoke(invocationKey) { + continue + } + + objectBeforeMutations := versionedAttr.VersionedObject + // Mutations for a single invocation of a MutatingAdmissionPolicy are evaluated + // in order. + for mutationIndex := range invocation.Policy.Spec.Mutations { + if invocationKeyErr != nil { + // This should never happen. It occurs if there is a programming + // error causing the Param not to be a valid object. + return nil, k8serrors.NewInternalError(invocationKeyErr) + } + + lastVersionedAttr = versionedAttr + if versionedAttr.VersionedObject == nil { // Do not call patchers if there is no object to patch. + continue + } + + patcher := invocation.Evaluator.Mutators[mutationIndex] + optionalVariables := cel.OptionalVariableBindings{VersionedParams: invocation.Param, Authorizer: authz} + err = d.dispatchOne(ctx, patcher, o, versionedAttr, namespace, invocation.Resource, optionalVariables) + if err != nil { + var statusError *k8serrors.StatusError + if errors.As(err, &statusError) { + return nil, statusError + } + + addConfigError(err, invocation, metav1.StatusReasonInvalid) + continue + } + } + if !apiequality.Semantic.DeepEqual(objectBeforeMutations, versionedAttr.VersionedObject) { + // The mutation has changed the object. Prepare to reinvoke all previous mutations that are eligible for re-invocation. + policyReinvokeCtx.RequireReinvokingPreviouslyInvokedPlugins() + reinvokeCtx.SetShouldReinvoke() + } + if invocation.Policy.Spec.ReinvocationPolicy == v1alpha1.IfNeededReinvocationPolicy { + policyReinvokeCtx.AddReinvocablePolicyToPreviouslyInvoked(invocationKey) + } + } + + if lastVersionedAttr != nil && lastVersionedAttr.VersionedObject != nil && lastVersionedAttr.Dirty { + policyReinvokeCtx.RequireReinvokingPreviouslyInvokedPlugins() + reinvokeCtx.SetShouldReinvoke() + if err := o.GetObjectConvertor().Convert(lastVersionedAttr.VersionedObject, lastVersionedAttr.Attributes.GetObject(), nil); err != nil { + return nil, k8serrors.NewInternalError(fmt.Errorf("failed to convert object: %w", err)) + } + } + + return policyErrors, nil +} + +func (d *dispatcher) dispatchOne( + ctx context.Context, + patcher patch.Patcher, + o admission.ObjectInterfaces, + versionedAttributes *admission.VersionedAttributes, + namespace *v1.Namespace, + resource schema.GroupVersionResource, + optionalVariables cel.OptionalVariableBindings, +) (err error) { + if patcher == nil { + // internal error. this should not happen + return k8serrors.NewInternalError(fmt.Errorf("policy evaluator is nil")) + } + + // Find type converter for the invoked Group-Version. + typeConverter := d.typeConverterManager.GetTypeConverter(versionedAttributes.VersionedKind) + if typeConverter == nil { + // This can happen if the request is for a resource whose schema + // has not been registered with the type converter manager. + return k8serrors.NewServiceUnavailable(fmt.Sprintf("Resource kind %s not found. There can be a delay between when CustomResourceDefinitions are created and when they are available.", versionedAttributes.VersionedKind)) + } + + patchRequest := patch.Request{ + MatchedResource: resource, + VersionedAttributes: versionedAttributes, + ObjectInterfaces: o, + OptionalVariables: optionalVariables, + Namespace: namespace, + TypeConverter: typeConverter, + } + newVersionedObject, err := patcher.Patch(ctx, patchRequest, celconfig.RuntimeCELCostBudget) + if err != nil { + return err + } + + versionedAttributes.Dirty = true + versionedAttributes.VersionedObject = newVersionedObject + o.GetObjectDefaulter().Default(newVersionedObject) + return nil +} + +func keyFor(invocation generic.PolicyInvocation[*Policy, *PolicyBinding, PolicyEvaluator]) (key, error) { + var paramUID types.NamespacedName + if invocation.Param != nil { + paramAccessor, err := meta.Accessor(invocation.Param) + if err != nil { + // This should never happen, as the param should have been validated + // before being passed to the plugin. + return key{}, err + } + paramUID = types.NamespacedName{ + Name: paramAccessor.GetName(), + Namespace: paramAccessor.GetNamespace(), + } + } + + return key{ + PolicyUID: types.NamespacedName{ + Name: invocation.Policy.GetName(), + Namespace: invocation.Policy.GetNamespace(), + }, + BindingUID: types.NamespacedName{ + Name: invocation.Binding.GetName(), + Namespace: invocation.Binding.GetNamespace(), + }, + ParamUID: paramUID, + }, nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/interface.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/interface.go new file mode 100644 index 00000000000..bf196461b0f --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/interface.go @@ -0,0 +1,60 @@ +/* +Copyright 2024 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 ( + celgo "github.com/google/cel-go/cel" + celtypes "github.com/google/cel-go/common/types" + + "k8s.io/apiserver/pkg/admission/plugin/cel" +) + +var _ cel.ExpressionAccessor = &ApplyConfigurationCondition{} + +// ApplyConfigurationCondition contains the inputs needed to compile and evaluate a cel expression +// that returns an apply configuration +type ApplyConfigurationCondition struct { + Expression string +} + +func (v *ApplyConfigurationCondition) GetExpression() string { + return v.Expression +} + +func (v *ApplyConfigurationCondition) ReturnTypes() []*celgo.Type { + return []*celgo.Type{applyConfigObjectType} +} + +var applyConfigObjectType = celtypes.NewObjectType("Object") + +var _ cel.ExpressionAccessor = &JSONPatchCondition{} + +// JSONPatchCondition contains the inputs needed to compile and evaluate a cel expression +// that returns a JSON patch value. +type JSONPatchCondition struct { + Expression string +} + +func (v *JSONPatchCondition) GetExpression() string { + return v.Expression +} + +func (v *JSONPatchCondition) ReturnTypes() []*celgo.Type { + return []*celgo.Type{celgo.ListType(jsonPatchType)} +} + +var jsonPatchType = celtypes.NewObjectType("JSONPatch") diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/interface.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/interface.go new file mode 100644 index 00000000000..d00d9837316 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/interface.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 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 patch + +import ( + "context" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/cel" +) + +// Patcher provides a patch function to perform a mutation to an object in the admission chain. +type Patcher interface { + Patch(ctx context.Context, request Request, runtimeCELCostBudget int64) (runtime.Object, error) +} + +// Request defines the arguments required by a patcher. +type Request struct { + MatchedResource schema.GroupVersionResource + VersionedAttributes *admission.VersionedAttributes + ObjectInterfaces admission.ObjectInterfaces + OptionalVariables cel.OptionalVariableBindings + Namespace *v1.Namespace + TypeConverter managedfields.TypeConverter +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/json_patch.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/json_patch.go new file mode 100644 index 00000000000..b5cf919ef16 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/json_patch.go @@ -0,0 +1,173 @@ +/* +Copyright 2024 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 patch + +import ( + "context" + gojson "encoding/json" + "errors" + "fmt" + "reflect" + "strconv" + + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/traits" + "google.golang.org/protobuf/types/known/structpb" + jsonpatch "gopkg.in/evanphx/json-patch.v4" + + admissionv1 "k8s.io/api/admission/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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/serializer/json" + plugincel "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/cel/mutation" + "k8s.io/apiserver/pkg/cel/mutation/dynamic" + pointer "k8s.io/utils/ptr" +) + +// NewJSONPatcher creates a patcher that performs a JSON Patch mutation. +func NewJSONPatcher(patchEvaluator plugincel.MutatingEvaluator) Patcher { + return &jsonPatcher{patchEvaluator} +} + +type jsonPatcher struct { + PatchEvaluator plugincel.MutatingEvaluator +} + +func (e *jsonPatcher) Patch(ctx context.Context, r Request, runtimeCELCostBudget int64) (runtime.Object, error) { + admissionRequest := plugincel.CreateAdmissionRequest( + r.VersionedAttributes.Attributes, + metav1.GroupVersionResource(r.MatchedResource), + metav1.GroupVersionKind(r.VersionedAttributes.VersionedKind)) + + compileErrors := e.PatchEvaluator.CompilationErrors() + if len(compileErrors) > 0 { + return nil, errors.Join(compileErrors...) + } + patchObj, _, err := e.evaluatePatchExpression(ctx, e.PatchEvaluator, runtimeCELCostBudget, r, admissionRequest) + if err != nil { + return nil, err + } + o := r.ObjectInterfaces + jsonSerializer := json.NewSerializerWithOptions(json.DefaultMetaFactory, o.GetObjectCreater(), o.GetObjectTyper(), json.SerializerOptions{Pretty: false, Strict: true}) + objJS, err := runtime.Encode(jsonSerializer, r.VersionedAttributes.VersionedObject) + if err != nil { + return nil, fmt.Errorf("failed to create JSON patch: %w", err) + } + patchedJS, err := patchObj.Apply(objJS) + if err != nil { + if errors.Is(err, jsonpatch.ErrTestFailed) { + // If a json patch fails a test operation, the patch must not be applied + return r.VersionedAttributes.VersionedObject, nil + } + return nil, fmt.Errorf("JSON Patch: %w", err) + } + + var newVersionedObject runtime.Object + if _, ok := r.VersionedAttributes.VersionedObject.(*unstructured.Unstructured); ok { + newVersionedObject = &unstructured.Unstructured{} + } else { + newVersionedObject, err = o.GetObjectCreater().New(r.VersionedAttributes.VersionedKind) + if err != nil { + return nil, apierrors.NewInternalError(err) + } + } + + if newVersionedObject, _, err = jsonSerializer.Decode(patchedJS, nil, newVersionedObject); err != nil { + return nil, apierrors.NewInternalError(err) + } + + return newVersionedObject, nil +} + +func (e *jsonPatcher) evaluatePatchExpression(ctx context.Context, patchEvaluator plugincel.MutatingEvaluator, remainingBudget int64, r Request, admissionRequest *admissionv1.AdmissionRequest) (jsonpatch.Patch, int64, error) { + var err error + var eval plugincel.EvaluationResult + eval, remainingBudget, err = patchEvaluator.ForInput(ctx, r.VersionedAttributes, admissionRequest, r.OptionalVariables, r.Namespace, remainingBudget) + if err != nil { + return nil, -1, err + } + if eval.Error != nil { + return nil, -1, eval.Error + } + refVal := eval.EvalResult + + // the return type can be any valid CEL value. + // Scalars, maps and lists are used to set the value when the path points to a field of that type. + // ObjectVal is used when the path points to a struct. A map like "{"field1": 1, "fieldX": bool}" is not + // possible in Kubernetes CEL because maps and lists may not have mixed types. + + iter, ok := refVal.(traits.Lister) + if !ok { + // Should never happen since compiler checks return type. + return nil, -1, fmt.Errorf("type mismatch: JSONPatchType.expression should evaluate to array") + } + result := jsonpatch.Patch{} + for it := iter.Iterator(); it.HasNext() == types.True; { + v := it.Next() + patchObj, err := v.ConvertToNative(reflect.TypeOf(&mutation.JSONPatchVal{})) + if err != nil { + // Should never happen since return type is checked by compiler. + return nil, -1, fmt.Errorf("type mismatch: JSONPatchType.expression should evaluate to array of JSONPatch: %w", err) + } + op, ok := patchObj.(*mutation.JSONPatchVal) + if !ok { + // Should never happen since return type is checked by compiler. + return nil, -1, fmt.Errorf("type mismatch: JSONPatchType.expression should evaluate to array of JSONPatch, got element of %T", patchObj) + } + + // Construct a JSON Patch from the evaluated CEL expression + resultOp := jsonpatch.Operation{} + resultOp["op"] = pointer.To(gojson.RawMessage(strconv.Quote(op.Op))) + resultOp["path"] = pointer.To(gojson.RawMessage(strconv.Quote(op.Path))) + if len(op.From) > 0 { + resultOp["from"] = pointer.To(gojson.RawMessage(strconv.Quote(op.From))) + } + if op.Val != nil { + if objVal, ok := op.Val.(*dynamic.ObjectVal); ok { + // TODO: Object initializers are insufficiently type checked. + // In the interim, we use this sanity check to detect type mismatches + // between field names and Object initializers. For example, + // "Object.spec{ selector: Object.spec.wrong{}}" is detected as a mismatch. + // Before beta, attaching full type information both to Object initializers and + // the "object" and "oldObject" variables is needed. This will allow CEL to + // perform comprehensive runtime type checking. + err := objVal.CheckTypeNamesMatchFieldPathNames() + if err != nil { + return nil, -1, fmt.Errorf("type mismatch: %w", err) + } + } + // CEL data literals representing arbitrary JSON values can be serialized to JSON for use in + // JSON Patch if first converted to pb.Value. + v, err := op.Val.ConvertToNative(reflect.TypeOf(&structpb.Value{})) + if err != nil { + return nil, -1, fmt.Errorf("JSONPath valueExpression evaluated to a type that could not marshal to JSON: %w", err) + } + b, err := gojson.Marshal(v) + if err != nil { + return nil, -1, fmt.Errorf("JSONPath valueExpression evaluated to a type that could not marshal to JSON: %w", err) + } + resultOp["value"] = pointer.To[gojson.RawMessage](b) + } + + result = append(result, resultOp) + } + + return result, remainingBudget, nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/smd.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/smd.go new file mode 100644 index 00000000000..d0e5ca1fa05 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/smd.go @@ -0,0 +1,194 @@ +/* +Copyright 2024 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 patch + +import ( + "context" + "errors" + "fmt" + "strings" + + "sigs.k8s.io/structured-merge-diff/v4/fieldpath" + "sigs.k8s.io/structured-merge-diff/v4/schema" + "sigs.k8s.io/structured-merge-diff/v4/typed" + "sigs.k8s.io/structured-merge-diff/v4/value" + + 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/util/managedfields" + plugincel "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/cel/mutation/dynamic" +) + +// NewApplyConfigurationPatcher creates a patcher that performs an applyConfiguration mutation. +func NewApplyConfigurationPatcher(expressionEvaluator plugincel.MutatingEvaluator) Patcher { + return &applyConfigPatcher{expressionEvaluator: expressionEvaluator} +} + +type applyConfigPatcher struct { + expressionEvaluator plugincel.MutatingEvaluator +} + +func (e *applyConfigPatcher) Patch(ctx context.Context, r Request, runtimeCELCostBudget int64) (runtime.Object, error) { + admissionRequest := plugincel.CreateAdmissionRequest( + r.VersionedAttributes.Attributes, + metav1.GroupVersionResource(r.MatchedResource), + metav1.GroupVersionKind(r.VersionedAttributes.VersionedKind)) + + compileErrors := e.expressionEvaluator.CompilationErrors() + if len(compileErrors) > 0 { + return nil, errors.Join(compileErrors...) + } + eval, _, err := e.expressionEvaluator.ForInput(ctx, r.VersionedAttributes, admissionRequest, r.OptionalVariables, r.Namespace, runtimeCELCostBudget) + if err != nil { + return nil, err + } + if eval.Error != nil { + return nil, eval.Error + } + v := eval.EvalResult + + // The compiler ensures that the return type is an ObjectVal with type name of "Object". + objVal, ok := v.(*dynamic.ObjectVal) + if !ok { + // Should not happen since the compiler type checks the return type. + return nil, fmt.Errorf("unsupported return type from ApplyConfiguration expression: %v", v.Type()) + } + // TODO: Object initializers are insufficiently type checked. + // In the interim, we use this sanity check to detect type mismatches + // between field names and Object initializers. For example, + // "Object.spec{ selector: Object.spec.wrong{}}" is detected as a mismatch. + // Before beta, attaching full type information both to Object initializers and + // the "object" and "oldObject" variables is needed. This will allow CEL to + // perform comprehensive runtime type checking. + err = objVal.CheckTypeNamesMatchFieldPathNames() + if err != nil { + return nil, fmt.Errorf("type mismatch: %w", err) + } + + value, ok := objVal.Value().(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid return type: %T", v) + } + + patchObject := unstructured.Unstructured{Object: value} + patchObject.SetGroupVersionKind(r.VersionedAttributes.VersionedObject.GetObjectKind().GroupVersionKind()) + patched, err := ApplyStructuredMergeDiff(r.TypeConverter, r.VersionedAttributes.VersionedObject, &patchObject) + if err != nil { + return nil, fmt.Errorf("error applying patch: %w", err) + } + return patched, nil +} + +// ApplyStructuredMergeDiff applies a structured merge diff to an object and returns a copy of the object +// with the patch applied. +func ApplyStructuredMergeDiff( + typeConverter managedfields.TypeConverter, + originalObject runtime.Object, + patch *unstructured.Unstructured, +) (runtime.Object, error) { + if patch.GroupVersionKind() != originalObject.GetObjectKind().GroupVersionKind() { + return nil, fmt.Errorf("patch (%v) and original object (%v) are not of the same gvk", patch.GroupVersionKind().String(), originalObject.GetObjectKind().GroupVersionKind().String()) + } else if typeConverter == nil { + return nil, fmt.Errorf("type converter must not be nil") + } + + patchObjTyped, err := typeConverter.ObjectToTyped(patch) + if err != nil { + return nil, fmt.Errorf("failed to convert patch object to typed object: %w", err) + } + + err = validatePatch(patchObjTyped) + if err != nil { + return nil, fmt.Errorf("invalid ApplyConfiguration: %w", err) + } + + liveObjTyped, err := typeConverter.ObjectToTyped(originalObject) + if err != nil { + return nil, fmt.Errorf("failed to convert original object to typed object: %w", err) + } + + newObjTyped, err := liveObjTyped.Merge(patchObjTyped) + if err != nil { + return nil, fmt.Errorf("failed to merge patch: %w", err) + } + + // Our mutating admission policy sets the fields but does not track ownership. + // Newly introduced fields in the patch won't be tracked by a field manager + // (so if the original object is updated again but the mutating policy is + // not active, the fields will be dropped). + // + // This necessarily means that changes to an object by a mutating policy + // are only preserved if the policy was active at the time of the change. + // (If the policy is not active, the changes may be dropped.) + + newObj, err := typeConverter.TypedToObject(newObjTyped) + if err != nil { + return nil, fmt.Errorf("failed to convert typed object to object: %w", err) + } + + return newObj, nil +} + +// validatePatch searches an apply configuration for any arrays, maps or structs elements that are atomic and returns +// an error if any are found. +func validatePatch(v *typed.TypedValue) error { + atomics := findAtomics(nil, v.Schema(), v.TypeRef(), v.AsValue()) + if len(atomics) > 0 { + return fmt.Errorf("may not mutate atomic arrays, maps or structs: %v", strings.Join(atomics, ", ")) + } + return nil +} + +// findAtomics returns field paths for any atomic arrays, maps or structs found when traversing the given value. +func findAtomics(path []fieldpath.PathElement, s *schema.Schema, tr schema.TypeRef, v value.Value) (atomics []string) { + if a, ok := s.Resolve(tr); ok { // Validation pass happens before this and checks that all schemas can be resolved + if v.IsMap() && a.Map != nil { + if a.Map.ElementRelationship == schema.Atomic { + atomics = append(atomics, pathString(path)) + } + v.AsMap().Iterate(func(key string, val value.Value) bool { + pe := fieldpath.PathElement{FieldName: &key} + if sf, ok := a.Map.FindField(key); ok { + tr = sf.Type + atomics = append(atomics, findAtomics(append(path, pe), s, tr, val)...) + } + return true + }) + } + if v.IsList() && a.List != nil { + if a.List.ElementRelationship == schema.Atomic { + atomics = append(atomics, pathString(path)) + } + list := v.AsList() + for i := 0; i < list.Length(); i++ { + pe := fieldpath.PathElement{Index: &i} + atomics = append(atomics, findAtomics(append(path, pe), s, a.List.ElementType, list.At(i))...) + } + } + } + return atomics +} + +func pathString(path []fieldpath.PathElement) string { + sb := strings.Builder{} + for _, p := range path { + sb.WriteString(p.String()) + } + return sb.String() +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/typeconverter.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/typeconverter.go new file mode 100644 index 00000000000..96ca7f03724 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch/typeconverter.go @@ -0,0 +1,187 @@ +/* +Copyright 2024 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 patch + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/openapi" + "k8s.io/kube-openapi/pkg/spec3" +) + +type TypeConverterManager interface { + // GetTypeConverter returns a type converter for the given GVK + GetTypeConverter(gvk schema.GroupVersionKind) managedfields.TypeConverter + Run(ctx context.Context) +} + +func NewTypeConverterManager( + staticTypeConverter managedfields.TypeConverter, + openapiClient openapi.Client, +) TypeConverterManager { + return &typeConverterManager{ + staticTypeConverter: staticTypeConverter, + openapiClient: openapiClient, + typeConverterMap: make(map[schema.GroupVersion]typeConverterCacheEntry), + lastFetchedPaths: make(map[schema.GroupVersion]openapi.GroupVersion), + } +} + +type typeConverterCacheEntry struct { + typeConverter managedfields.TypeConverter + entry openapi.GroupVersion +} + +// typeConverterManager helps us make sure we have an up to date schema and +// type converter for our openapi models. It should be connfigured to use a +// static type converter for natively typed schemas, and fetches the schema +// for CRDs/other over the network on demand (trying to reduce network calls where necessary) +type typeConverterManager struct { + // schemaCache is used to cache the schema for a given GVK + staticTypeConverter managedfields.TypeConverter + + // discoveryClient is used to fetch the schema for a given GVK + openapiClient openapi.Client + + lock sync.RWMutex + + typeConverterMap map[schema.GroupVersion]typeConverterCacheEntry + lastFetchedPaths map[schema.GroupVersion]openapi.GroupVersion +} + +func (t *typeConverterManager) Run(ctx context.Context) { + // Loop every 5s refershing the OpenAPI schema list to know which + // schemas have been invalidated. This should use e-tags under the hood + _ = wait.PollUntilContextCancel(ctx, 5*time.Second, true, func(_ context.Context) (done bool, err error) { + paths, err := t.openapiClient.Paths() + if err != nil { + utilruntime.HandleError(fmt.Errorf("failed to fetch openapi paths: %w", err)) + return false, nil + } + + // The /openapi/v3 endpoint contains a list of paths whose ServerRelativeURL + // value changes every time the schema is updated. So we poll /openapi/v3 + // to get the "version number" for each schema, and invalidate our cache + // if the version number has changed since we pulled it. + parsedPaths := make(map[schema.GroupVersion]openapi.GroupVersion, len(paths)) + for path, entry := range paths { + if !strings.HasPrefix(path, "apis/") && !strings.HasPrefix(path, "api/") { + continue + } + path = strings.TrimPrefix(path, "apis/") + path = strings.TrimPrefix(path, "api/") + + gv, err := schema.ParseGroupVersion(path) + if err != nil { + utilruntime.HandleError(fmt.Errorf("failed to parse group version %q: %w", path, err)) + return false, nil + } + + parsedPaths[gv] = entry + } + + t.lock.Lock() + defer t.lock.Unlock() + t.lastFetchedPaths = parsedPaths + return false, nil + }) +} + +func (t *typeConverterManager) GetTypeConverter(gvk schema.GroupVersionKind) managedfields.TypeConverter { + // Check to see if the static type converter handles this GVK + if t.staticTypeConverter != nil { + //!TODO: Add ability to check existence to type converter + // working around for now but seeing if getting a typed version of an + // empty object returns error + stub := &unstructured.Unstructured{} + stub.SetGroupVersionKind(gvk) + + if _, err := t.staticTypeConverter.ObjectToTyped(stub); err == nil { + return t.staticTypeConverter + } + } + + gv := gvk.GroupVersion() + + existing, entry, err := func() (managedfields.TypeConverter, openapi.GroupVersion, error) { + t.lock.RLock() + defer t.lock.RUnlock() + + // If schema is not supported by static type converter, ask discovery + // for the schema + entry, ok := t.lastFetchedPaths[gv] + if !ok { + // If we can't get the schema, we can't do anything + return nil, nil, fmt.Errorf("no schema for %v", gvk) + } + + // If the entry schema has not changed, used the same type converter + if existing, ok := t.typeConverterMap[gv]; ok && existing.entry.ServerRelativeURL() == entry.ServerRelativeURL() { + // If we have a type converter for this GVK, return it + return existing.typeConverter, existing.entry, nil + } + + return nil, entry, nil + }() + if err != nil { + utilruntime.HandleError(err) + return nil + } else if existing != nil { + return existing + } + + schBytes, err := entry.Schema(runtime.ContentTypeJSON) + if err != nil { + utilruntime.HandleError(fmt.Errorf("failed to get schema for %v: %w", gvk, err)) + return nil + } + + var sch spec3.OpenAPI + if err := json.Unmarshal(schBytes, &sch); err != nil { + utilruntime.HandleError(fmt.Errorf("failed to unmarshal schema for %v: %w", gvk, err)) + return nil + } + + // The schema has changed, or there is no entry for it, generate + // a new type converter for this GV + tc, err := managedfields.NewTypeConverter(sch.Components.Schemas, false) + if err != nil { + utilruntime.HandleError(fmt.Errorf("failed to create type converter for %v: %w", gvk, err)) + return nil + } + + t.lock.Lock() + defer t.lock.Unlock() + + t.typeConverterMap[gv] = typeConverterCacheEntry{ + typeConverter: tc, + entry: entry, + } + + return tc +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin.go new file mode 100644 index 00000000000..fa84539efe3 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin.go @@ -0,0 +1,151 @@ +/* +Copyright 2024 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" + celgo "github.com/google/cel-go/cel" + "io" + + "k8s.io/api/admissionregistration/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/managedfields" + "k8s.io/apiserver/pkg/admission" + "k8s.io/apiserver/pkg/admission/plugin/cel" + "k8s.io/apiserver/pkg/admission/plugin/policy/generic" + "k8s.io/apiserver/pkg/admission/plugin/policy/matching" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch" + "k8s.io/apiserver/pkg/admission/plugin/webhook/matchconditions" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/features" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/component-base/featuregate" +) + +const ( + // PluginName indicates the name of admission plug-in + PluginName = "MutatingAdmissionPolicy" +) + +// Register registers a plugin +func Register(plugins *admission.Plugins) { + plugins.Register(PluginName, func(configFile io.Reader) (admission.Interface, error) { + return NewPlugin(configFile), nil + }) +} + +// Plugin is an implementation of admission.Interface. +type Policy = v1alpha1.MutatingAdmissionPolicy +type PolicyBinding = v1alpha1.MutatingAdmissionPolicyBinding +type PolicyMutation = v1alpha1.Mutation +type PolicyHook = generic.PolicyHook[*Policy, *PolicyBinding, PolicyEvaluator] + +type Mutator struct { +} +type MutationEvaluationFunc func( + ctx context.Context, + matchedResource schema.GroupVersionResource, + versionedAttr *admission.VersionedAttributes, + o admission.ObjectInterfaces, + versionedParams runtime.Object, + namespace *corev1.Namespace, + typeConverter managedfields.TypeConverter, + runtimeCELCostBudget int64, + authorizer authorizer.Authorizer, +) (runtime.Object, error) + +type PolicyEvaluator struct { + Matcher matchconditions.Matcher + Mutators []patch.Patcher + CompositionEnv *cel.CompositionEnv + Error error +} + +type Plugin struct { + *generic.Plugin[PolicyHook] +} + +var _ admission.Interface = &Plugin{} +var _ admission.MutationInterface = &Plugin{} + +// NewPlugin returns a generic admission webhook plugin. +func NewPlugin(_ io.Reader) *Plugin { + // There is no request body to mutate for DELETE, so this plugin never handles that operation. + handler := admission.NewHandler(admission.Create, admission.Update, admission.Connect) + res := &Plugin{} + res.Plugin = generic.NewPlugin( + handler, + func(f informers.SharedInformerFactory, client kubernetes.Interface, dynamicClient dynamic.Interface, restMapper meta.RESTMapper) generic.Source[PolicyHook] { + return generic.NewPolicySource( + f.Admissionregistration().V1alpha1().MutatingAdmissionPolicies().Informer(), + f.Admissionregistration().V1alpha1().MutatingAdmissionPolicyBindings().Informer(), + NewMutatingAdmissionPolicyAccessor, + NewMutatingAdmissionPolicyBindingAccessor, + compilePolicy, + //!TODO: Create a way to share param informers between + // mutating/validating plugins + f, + dynamicClient, + restMapper, + ) + }, + func(a authorizer.Authorizer, m *matching.Matcher, client kubernetes.Interface) generic.Dispatcher[PolicyHook] { + return NewDispatcher(a, m, patch.NewTypeConverterManager(nil, client.Discovery().OpenAPIV3())) + }, + ) + return res +} + +// Admit makes an admission decision based on the request attributes. +func (a *Plugin) Admit(ctx context.Context, attr admission.Attributes, o admission.ObjectInterfaces) error { + return a.Plugin.Dispatch(ctx, attr, o) +} + +func (a *Plugin) InspectFeatureGates(featureGates featuregate.FeatureGate) { + a.Plugin.SetEnabled(featureGates.Enabled(features.MutatingAdmissionPolicy)) +} + +// Variable is a named expression for composition. +type Variable struct { + Name string + Expression string +} + +func (v *Variable) GetExpression() string { + return v.Expression +} + +func (v *Variable) ReturnTypes() []*celgo.Type { + return []*celgo.Type{celgo.AnyType, celgo.DynType} +} + +func (v *Variable) GetName() string { + return v.Name +} + +func convertv1alpha1Variables(variables []v1alpha1.Variable) []cel.NamedExpressionAccessor { + namedExpressions := make([]cel.NamedExpressionAccessor, len(variables)) + for i, variable := range variables { + namedExpressions[i] = &Variable{Name: variable.Name, Expression: variable.Expression} + } + return namedExpressions +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin_test.go new file mode 100644 index 00000000000..884e7dec1e9 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/plugin_test.go @@ -0,0 +1,292 @@ +/* +Copyright 2024 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_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "k8s.io/api/admissionregistration/v1alpha1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + 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/policy/generic" + "k8s.io/apiserver/pkg/admission/plugin/policy/matching" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating" + "k8s.io/apiserver/pkg/admission/plugin/policy/mutating/patch" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/openapi/openapitest" + "k8s.io/utils/ptr" +) + +func setupTest( + t *testing.T, + compiler func(*mutating.Policy) mutating.PolicyEvaluator, +) *generic.PolicyTestContext[*mutating.Policy, *mutating.PolicyBinding, mutating.PolicyEvaluator] { + + testContext, testCancel, err := generic.NewPolicyTestContext[*mutating.Policy, *mutating.PolicyBinding, mutating.PolicyEvaluator]( + mutating.NewMutatingAdmissionPolicyAccessor, + mutating.NewMutatingAdmissionPolicyBindingAccessor, + compiler, + func(a authorizer.Authorizer, m *matching.Matcher, i kubernetes.Interface) generic.Dispatcher[mutating.PolicyHook] { + // Use embedded schemas rather than discovery schemas + return mutating.NewDispatcher(a, m, patch.NewTypeConverterManager(nil, openapitest.NewEmbeddedFileClient())) + }, + nil, + []meta.RESTMapping{ + { + Resource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "pods", + }, + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + Scope: meta.RESTScopeNamespace, + }, + }) + require.NoError(t, err) + t.Cleanup(testCancel) + require.NoError(t, testContext.Start()) + return testContext +} + +// Show that a compiler that always sets an annotation on the object works +func TestBasicPatch(t *testing.T) { + expectedAnnotations := map[string]string{"foo": "bar"} + + // Treat all policies as setting foo annotation to bar + testContext := setupTest(t, func(p *mutating.Policy) mutating.PolicyEvaluator { + return mutating.PolicyEvaluator{Mutators: []patch.Patcher{annotationPatcher{expectedAnnotations}}} + }) + + // Set up a policy and binding that match, no params + require.NoError(t, testContext.UpdateAndWait( + &mutating.Policy{ + ObjectMeta: metav1.ObjectMeta{Name: "policy"}, + Spec: v1alpha1.MutatingAdmissionPolicySpec{ + MatchConstraints: &v1alpha1.MatchResources{ + MatchPolicy: ptr.To(v1alpha1.Equivalent), + NamespaceSelector: &metav1.LabelSelector{}, + ObjectSelector: &metav1.LabelSelector{}, + }, + Mutations: []v1alpha1.Mutation{ + { + ApplyConfiguration: &v1alpha1.ApplyConfiguration{ + Expression: "ignored, but required", + }, + PatchType: v1alpha1.PatchTypeApplyConfiguration, + }, + }, + }, + }, + &mutating.PolicyBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "binding"}, + Spec: v1alpha1.MutatingAdmissionPolicyBindingSpec{ + PolicyName: "policy", + }, + }, + )) + + // Show that if we run an object through the policy, it gets the annotation + testObject := &corev1.ConfigMap{} + err := testContext.Dispatch(testObject, nil, admission.Create) + require.NoError(t, err) + require.Equal(t, expectedAnnotations, testObject.Annotations) +} + +func TestSSAPatch(t *testing.T) { + patchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + "data": map[string]interface{}{ + "myfield": "myvalue", + }, + }, + } + + testContext := setupTest(t, func(p *mutating.Policy) mutating.PolicyEvaluator { + return mutating.PolicyEvaluator{ + Mutators: []patch.Patcher{smdPatcher{patch: patchObj}}, + } + }) + + // Set up a policy and binding that match, no params + require.NoError(t, testContext.UpdateAndWait( + &mutating.Policy{ + ObjectMeta: metav1.ObjectMeta{Name: "policy"}, + Spec: v1alpha1.MutatingAdmissionPolicySpec{ + MatchConstraints: &v1alpha1.MatchResources{ + MatchPolicy: ptr.To(v1alpha1.Equivalent), + NamespaceSelector: &metav1.LabelSelector{}, + ObjectSelector: &metav1.LabelSelector{}, + }, + Mutations: []v1alpha1.Mutation{ + { + ApplyConfiguration: &v1alpha1.ApplyConfiguration{ + Expression: "ignored, but required", + }, + PatchType: v1alpha1.PatchTypeApplyConfiguration, + }, + }, + }, + }, + &mutating.PolicyBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "binding"}, + Spec: v1alpha1.MutatingAdmissionPolicyBindingSpec{ + PolicyName: "policy", + }, + }, + )) + + // Show that if we run an object through the policy, it gets the annotation + testObject := &corev1.ConfigMap{} + err := testContext.Dispatch(testObject, nil, admission.Create) + require.NoError(t, err) + require.Equal(t, &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"foo": "bar"}, + }, + Data: map[string]string{"myfield": "myvalue"}, + }, testObject) +} + +func TestSSAMapList(t *testing.T) { + patchObj := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]interface{}{ + "annotations": map[string]interface{}{ + "foo": "bar", + }, + }, + "spec": map[string]interface{}{ + "initContainers": []interface{}{ + map[string]interface{}{ + "name": "injected-init-container", + "image": "injected-image", + }, + }, + }, + }, + } + + testContext := setupTest(t, func(p *mutating.Policy) mutating.PolicyEvaluator { + return mutating.PolicyEvaluator{ + Mutators: []patch.Patcher{smdPatcher{patch: patchObj}}, + } + }) + + // Set up a policy and binding that match, no params + require.NoError(t, testContext.UpdateAndWait( + &mutating.Policy{ + ObjectMeta: metav1.ObjectMeta{Name: "policy"}, + Spec: v1alpha1.MutatingAdmissionPolicySpec{ + MatchConstraints: &v1alpha1.MatchResources{ + MatchPolicy: ptr.To(v1alpha1.Equivalent), + NamespaceSelector: &metav1.LabelSelector{}, + ObjectSelector: &metav1.LabelSelector{}, + }, + Mutations: []v1alpha1.Mutation{ + { + ApplyConfiguration: &v1alpha1.ApplyConfiguration{ + Expression: "ignored, but required", + }, + PatchType: v1alpha1.PatchTypeApplyConfiguration, + }, + }, + }, + }, + &mutating.PolicyBinding{ + ObjectMeta: metav1.ObjectMeta{Name: "binding"}, + Spec: v1alpha1.MutatingAdmissionPolicyBindingSpec{ + PolicyName: "policy", + }, + }, + )) + + // Show that if we run an object through the policy, it gets the annotation + testObject := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init-container", + Image: "image", + }, + }, + }, + } + err := testContext.Dispatch(testObject, nil, admission.Create) + require.NoError(t, err) + require.Equal(t, &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"foo": "bar"}, + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{ + { + Name: "init-container", + Image: "image", + }, + { + Name: "injected-init-container", + Image: "injected-image", + }, + }, + }, + }, testObject) +} + +type annotationPatcher struct { + annotations map[string]string +} + +func (ap annotationPatcher) Patch(ctx context.Context, request patch.Request, runtimeCELCostBudget int64) (runtime.Object, error) { + obj := request.VersionedAttributes.VersionedObject.DeepCopyObject() + accessor, err := meta.Accessor(obj) + if err != nil { + return nil, err + } + accessor.SetAnnotations(ap.annotations) + return obj, nil +} + +type smdPatcher struct { + patch *unstructured.Unstructured +} + +func (sp smdPatcher) Patch(ctx context.Context, request patch.Request, runtimeCELCostBudget int64) (runtime.Object, error) { + return patch.ApplyStructuredMergeDiff(request.TypeConverter, request.VersionedAttributes.VersionedObject, sp.patch) +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/reinvocationcontext.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/reinvocationcontext.go new file mode 100644 index 00000000000..4ba030c283d --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/mutating/reinvocationcontext.go @@ -0,0 +1,76 @@ +/* +Copyright 2019 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 ( + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" +) + +type key struct { + PolicyUID types.NamespacedName + BindingUID types.NamespacedName + ParamUID types.NamespacedName + MutationIndex int +} + +type policyReinvokeContext struct { + // lastPolicyOutput holds the result of the last Policy admission plugin call + lastPolicyOutput runtime.Object + // previouslyInvokedReinvocablePolicys holds the set of policies that have been invoked and + // should be reinvoked if a later mutation occurs + previouslyInvokedReinvocablePolicies sets.Set[key] + // reinvokePolicies holds the set of Policies that should be reinvoked + reinvokePolicies sets.Set[key] +} + +func (rc *policyReinvokeContext) ShouldReinvoke(policy key) bool { + return rc.reinvokePolicies.Has(policy) +} + +func (rc *policyReinvokeContext) IsOutputChangedSinceLastPolicyInvocation(object runtime.Object) bool { + return !apiequality.Semantic.DeepEqual(rc.lastPolicyOutput, object) +} + +func (rc *policyReinvokeContext) SetLastPolicyInvocationOutput(object runtime.Object) { + if object == nil { + rc.lastPolicyOutput = nil + return + } + rc.lastPolicyOutput = object.DeepCopyObject() +} + +func (rc *policyReinvokeContext) AddReinvocablePolicyToPreviouslyInvoked(policy key) { + if rc.previouslyInvokedReinvocablePolicies == nil { + rc.previouslyInvokedReinvocablePolicies = sets.New[key]() + } + rc.previouslyInvokedReinvocablePolicies.Insert(policy) +} + +func (rc *policyReinvokeContext) RequireReinvokingPreviouslyInvokedPlugins() { + if len(rc.previouslyInvokedReinvocablePolicies) > 0 { + if rc.reinvokePolicies == nil { + rc.reinvokePolicies = sets.New[key]() + } + for s := range rc.previouslyInvokedReinvocablePolicies { + rc.reinvokePolicies.Insert(s) + } + rc.previouslyInvokedReinvocablePolicies = sets.New[key]() + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/accessor.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/accessor.go index 97cef091480..628e3a65329 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/accessor.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/accessor.go @@ -54,6 +54,10 @@ func (v *validatingAdmissionPolicyAccessor) GetMatchConstraints() *v1.MatchResou return v.Spec.MatchConstraints } +func (v *validatingAdmissionPolicyAccessor) GetFailurePolicy() *v1.FailurePolicyType { + return v.Spec.FailurePolicy +} + type validatingAdmissionPolicyBindingAccessor struct { *v1.ValidatingAdmissionPolicyBinding } diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/admission_test.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/admission_test.go index 0b542e540fa..14f33b17594 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/admission_test.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/admission_test.go @@ -45,6 +45,7 @@ import ( auditinternal "k8s.io/apiserver/pkg/apis/audit" "k8s.io/apiserver/pkg/authorization/authorizer" "k8s.io/apiserver/pkg/warning" + "k8s.io/client-go/kubernetes" ) var ( @@ -364,7 +365,7 @@ func setupTestCommon( func(p *validating.Policy) validating.Validator { return compiler.CompilePolicy(p) }, - func(a authorizer.Authorizer, m *matching.Matcher) generic.Dispatcher[validating.PolicyHook] { + func(a authorizer.Authorizer, m *matching.Matcher, client kubernetes.Interface) generic.Dispatcher[validating.PolicyHook] { coolMatcher := matcher if coolMatcher == nil { coolMatcher = generic.NewPolicyMatcher(m) diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/dispatcher.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/dispatcher.go index f0601142530..5d47c94a25d 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/dispatcher.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/dispatcher.go @@ -30,6 +30,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" utiljson "k8s.io/apimachinery/pkg/util/json" "k8s.io/apiserver/pkg/admission" + admissionauthorizer "k8s.io/apiserver/pkg/admission/plugin/authorizer" "k8s.io/apiserver/pkg/admission/plugin/policy/generic" celmetrics "k8s.io/apiserver/pkg/admission/plugin/policy/validating/metrics" celconfig "k8s.io/apiserver/pkg/apis/cel" @@ -63,6 +64,10 @@ type policyDecisionWithMetadata struct { Binding *admissionregistrationv1.ValidatingAdmissionPolicyBinding } +func (c *dispatcher) Run(ctx context.Context) error { + return nil +} + // Dispatch implements generic.Dispatcher. func (c *dispatcher) Dispatch(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces, hooks []PolicyHook) error { @@ -109,7 +114,7 @@ func (c *dispatcher) Dispatch(ctx context.Context, a admission.Attributes, o adm } } - authz := newCachingAuthorizer(c.authz) + authz := admissionauthorizer.NewCachingAuthorizer(c.authz) for _, hook := range hooks { // versionedAttributes will be set to non-nil inside of the loop, but diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/plugin.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/plugin.go index 1621f368e03..85db23cd8a6 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/plugin.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/plugin.go @@ -112,7 +112,7 @@ func NewPlugin(_ io.Reader) *Plugin { restMapper, ) }, - func(a authorizer.Authorizer, m *matching.Matcher) generic.Dispatcher[PolicyHook] { + func(a authorizer.Authorizer, m *matching.Matcher, client kubernetes.Interface) generic.Dispatcher[PolicyHook] { return NewDispatcher(a, generic.NewPolicyMatcher(m)) }, ), @@ -151,13 +151,13 @@ func compilePolicy(policy *Policy) Validator { for i := range matchConditions { matchExpressionAccessors[i] = (*matchconditions.MatchCondition)(&matchConditions[i]) } - matcher = matchconditions.NewMatcher(filterCompiler.Compile(matchExpressionAccessors, optionalVars, environment.StoredExpressions), failurePolicy, "policy", "validate", policy.Name) + matcher = matchconditions.NewMatcher(filterCompiler.CompileCondition(matchExpressionAccessors, optionalVars, environment.StoredExpressions), failurePolicy, "policy", "validate", policy.Name) } res := NewValidator( - filterCompiler.Compile(convertv1Validations(policy.Spec.Validations), optionalVars, environment.StoredExpressions), + filterCompiler.CompileCondition(convertv1Validations(policy.Spec.Validations), optionalVars, environment.StoredExpressions), matcher, - filterCompiler.Compile(convertv1AuditAnnotations(policy.Spec.AuditAnnotations), optionalVars, environment.StoredExpressions), - filterCompiler.Compile(convertv1MessageExpressions(policy.Spec.Validations), expressionOptionalVars, environment.StoredExpressions), + filterCompiler.CompileCondition(convertv1AuditAnnotations(policy.Spec.AuditAnnotations), optionalVars, environment.StoredExpressions), + filterCompiler.CompileCondition(convertv1MessageExpressions(policy.Spec.Validations), expressionOptionalVars, environment.StoredExpressions), failurePolicy, ) diff --git a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/typechecking.go b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/typechecking.go index 192be9621bd..aa5a0f29407 100644 --- a/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/typechecking.go +++ b/staging/src/k8s.io/apiserver/pkg/admission/plugin/policy/validating/typechecking.go @@ -436,7 +436,7 @@ func buildEnvSet(hasParams bool, hasAuthorizer bool, types typeOverwrite) (*envi ) } -// createVariableOpts creates a slice of EnvOption +// createVariableOpts creates a slice of ResolverEnvOption // that can be used for creating a CEL env containing variables of declType. // declType can be nil, in which case the variables will be of DynType. func createVariableOpts(declType *apiservercel.DeclType, variables ...string) []cel.EnvOption { diff --git a/staging/src/k8s.io/apiserver/pkg/server/options/admission.go b/staging/src/k8s.io/apiserver/pkg/server/options/admission.go index 0a49cdc64dd..6b4669e4506 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/options/admission.go +++ b/staging/src/k8s.io/apiserver/pkg/server/options/admission.go @@ -31,6 +31,7 @@ import ( "k8s.io/apiserver/pkg/admission/initializer" admissionmetrics "k8s.io/apiserver/pkg/admission/metrics" "k8s.io/apiserver/pkg/admission/plugin/namespace/lifecycle" + mutatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/mutating" validatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/validating" mutatingwebhook "k8s.io/apiserver/pkg/admission/plugin/webhook/mutating" validatingwebhook "k8s.io/apiserver/pkg/admission/plugin/webhook/validating" @@ -90,7 +91,7 @@ func NewAdmissionOptions() *AdmissionOptions { // admission plugins. The apiserver always runs the validating ones // after all the mutating ones, so their relative order in this list // doesn't matter. - RecommendedPluginOrder: []string{lifecycle.PluginName, mutatingwebhook.PluginName, validatingadmissionpolicy.PluginName, validatingwebhook.PluginName}, + RecommendedPluginOrder: []string{lifecycle.PluginName, mutatingadmissionpolicy.PluginName, mutatingwebhook.PluginName, validatingadmissionpolicy.PluginName, validatingwebhook.PluginName}, DefaultOffPlugins: sets.Set[string]{}, } server.RegisterAllAdmissionPlugins(options.Plugins) diff --git a/staging/src/k8s.io/apiserver/pkg/server/options/admission_test.go b/staging/src/k8s.io/apiserver/pkg/server/options/admission_test.go index 3a4255e4355..50b39544c91 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/options/admission_test.go +++ b/staging/src/k8s.io/apiserver/pkg/server/options/admission_test.go @@ -36,7 +36,7 @@ func TestEnabledPluginNames(t *testing.T) { }{ // scenario 0: check if a call to enabledPluginNames sets expected values. { - expectedPluginNames: []string{"NamespaceLifecycle", "MutatingAdmissionWebhook", "ValidatingAdmissionPolicy", "ValidatingAdmissionWebhook"}, + expectedPluginNames: []string{"NamespaceLifecycle", "MutatingAdmissionPolicy", "MutatingAdmissionWebhook", "ValidatingAdmissionPolicy", "ValidatingAdmissionWebhook"}, }, // scenario 1: use default off plugins if no specified diff --git a/staging/src/k8s.io/apiserver/pkg/server/plugins.go b/staging/src/k8s.io/apiserver/pkg/server/plugins.go index 2390446419c..37a49af6b18 100644 --- a/staging/src/k8s.io/apiserver/pkg/server/plugins.go +++ b/staging/src/k8s.io/apiserver/pkg/server/plugins.go @@ -20,6 +20,7 @@ package server import ( "k8s.io/apiserver/pkg/admission" "k8s.io/apiserver/pkg/admission/plugin/namespace/lifecycle" + mutatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/mutating" validatingadmissionpolicy "k8s.io/apiserver/pkg/admission/plugin/policy/validating" mutatingwebhook "k8s.io/apiserver/pkg/admission/plugin/webhook/mutating" validatingwebhook "k8s.io/apiserver/pkg/admission/plugin/webhook/validating" @@ -31,4 +32,5 @@ func RegisterAllAdmissionPlugins(plugins *admission.Plugins) { validatingwebhook.Register(plugins) mutatingwebhook.Register(plugins) validatingadmissionpolicy.Register(plugins) + mutatingadmissionpolicy.Register(plugins) }