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) }