Matchconditions admission webhooks alpha implementation for kep-3716 (#116261)

* api changes adding match conditions

* feature gate and registry strategy to drop fields

* matchConditions logic for admission webhooks

* feedback

* update test

* import order

* bears.com

* update fail policy ignore behavior

* update docs and matcher to hold fail policy as non-pointer

* update matcher error aggregation, fix early fail failpolicy ignore, update docs

* final cleanup

* openapi gen

Kubernetes-commit: 5e5b3029f3bbfc93c3569f07ad300a5c6057fc58
This commit is contained in:
Igor Velichkovich 2023-03-14 22:28:26 -05:00 committed by Kubernetes Publisher
parent b841df9c51
commit 05d2078e68
17 changed files with 933 additions and 89 deletions

8
go.mod
View File

@ -42,9 +42,9 @@ require (
google.golang.org/protobuf v1.28.1
gopkg.in/natefinch/lumberjack.v2 v2.0.0
gopkg.in/square/go-jose.v2 v2.6.0
k8s.io/api v0.0.0-20230315055832-c79498c4e63b
k8s.io/api v0.0.0-20230315055831-abe66f57fdb1
k8s.io/apimachinery v0.0.0-20230315054728-8d1258da8f38
k8s.io/client-go v0.0.0-20230315061852-9ff2627505a4
k8s.io/client-go v0.0.0-20230315061912-38589731da69
k8s.io/component-base v0.0.0-20230315065615-6b9bb8ecc3d0
k8s.io/klog/v2 v2.90.1
k8s.io/kms v0.0.0-20230315071541-54e6d3479bfc
@ -124,9 +124,9 @@ require (
)
replace (
k8s.io/api => k8s.io/api v0.0.0-20230315055832-c79498c4e63b
k8s.io/api => k8s.io/api v0.0.0-20230315032826-0b4c449988b1
k8s.io/apimachinery => k8s.io/apimachinery v0.0.0-20230315054728-8d1258da8f38
k8s.io/client-go => k8s.io/client-go v0.0.0-20230315061852-9ff2627505a4
k8s.io/client-go => k8s.io/client-go v0.0.0-20230315061912-38589731da69
k8s.io/component-base => k8s.io/component-base v0.0.0-20230315065615-6b9bb8ecc3d0
k8s.io/kms => k8s.io/kms v0.0.0-20230315071541-54e6d3479bfc
)

8
go.sum
View File

@ -878,12 +878,12 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
k8s.io/api v0.0.0-20230315055832-c79498c4e63b h1:+XtheLjvEgurlWvtf13tutNwmW6WUHGezbiapc4s6EM=
k8s.io/api v0.0.0-20230315055832-c79498c4e63b/go.mod h1:aZ6MBt4NMLXSxkSKFkoDaP4hTutnZIvH5dCSpOis9g4=
k8s.io/api v0.0.0-20230315032826-0b4c449988b1 h1:wlCdY1kqV0RkfnfRr4mEZ3fGJ1VvLelr5Q2vCnCICIo=
k8s.io/api v0.0.0-20230315032826-0b4c449988b1/go.mod h1:aZ6MBt4NMLXSxkSKFkoDaP4hTutnZIvH5dCSpOis9g4=
k8s.io/apimachinery v0.0.0-20230315054728-8d1258da8f38 h1:n1qDRCTPAXwyXYg7eSpWDO9FdW79lwAQ9dAr1vETpn4=
k8s.io/apimachinery v0.0.0-20230315054728-8d1258da8f38/go.mod h1:5ikh59fK3AJ287GUvpUsryoMFtH9zj/ARfWCo3AyXTM=
k8s.io/client-go v0.0.0-20230315061852-9ff2627505a4 h1:mjPYL7mzIzB9PHI4ttnYkGY5zat6ObsndQNh7Gx12OQ=
k8s.io/client-go v0.0.0-20230315061852-9ff2627505a4/go.mod h1:daDXfDBtiJdqKrqqFL0WtQ6hHzsDxP1lCEdTt3+kijU=
k8s.io/client-go v0.0.0-20230315061912-38589731da69 h1:LnTXY9Akksk/aAmbebKIaC0doqk1aKbZQA3OoAd0BB0=
k8s.io/client-go v0.0.0-20230315061912-38589731da69/go.mod h1:b0alWGtfu+BI7XZwwdOHJIsr7aDjKf3ANThw8Sr+tw8=
k8s.io/component-base v0.0.0-20230315065615-6b9bb8ecc3d0 h1:IjneP02MOB07PIP9+PQjKrOIZEZ5T7umR+GIZkU4h0U=
k8s.io/component-base v0.0.0-20230315065615-6b9bb8ecc3d0/go.mod h1:kTuptveA6tUMLMKnaq4AbIAAk7IcdhwkbljAV3JZRpM=
k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw=

View File

@ -112,12 +112,13 @@ func (p pluginHandlerWithMetrics) Validate(ctx context.Context, a admission.Attr
// AdmissionMetrics instruments admission with prometheus metrics.
type AdmissionMetrics struct {
step *metricSet
controller *metricSet
webhook *metricSet
webhookRejection *metrics.CounterVec
webhookFailOpen *metrics.CounterVec
webhookRequest *metrics.CounterVec
step *metricSet
controller *metricSet
webhook *metricSet
webhookRejection *metrics.CounterVec
webhookFailOpen *metrics.CounterVec
webhookRequest *metrics.CounterVec
matchConditionEvalErrors *metrics.CounterVec
}
// newAdmissionMetrics create a new AdmissionMetrics, configured with default metric names.
@ -217,13 +218,24 @@ func newAdmissionMetrics() *AdmissionMetrics {
},
[]string{"name", "type", "operation", "code", "rejected"})
matchConditionEvalError := metrics.NewCounterVec(
&metrics.CounterOpts{
Namespace: namespace,
Subsystem: subsystem,
Name: "admission_match_condition_evaluation_errors_total",
Help: "Admission match condition evaluation errors count, identified by name of resource containing the match condition and broken out for each admission type (validating or mutating).",
StabilityLevel: metrics.ALPHA,
},
[]string{"name", "type"})
step.mustRegister()
controller.mustRegister()
webhook.mustRegister()
legacyregistry.MustRegister(webhookRejection)
legacyregistry.MustRegister(webhookFailOpen)
legacyregistry.MustRegister(webhookRequest)
return &AdmissionMetrics{step: step, controller: controller, webhook: webhook, webhookRejection: webhookRejection, webhookFailOpen: webhookFailOpen, webhookRequest: webhookRequest}
legacyregistry.MustRegister(matchConditionEvalError)
return &AdmissionMetrics{step: step, controller: controller, webhook: webhook, webhookRejection: webhookRejection, webhookFailOpen: webhookFailOpen, webhookRequest: webhookRequest, matchConditionEvalErrors: matchConditionEvalError}
}
func (m *AdmissionMetrics) reset() {
@ -267,6 +279,11 @@ func (m *AdmissionMetrics) ObserveWebhookFailOpen(ctx context.Context, name, ste
m.webhookFailOpen.WithContext(ctx).WithLabelValues(name, stepType).Inc()
}
// ObserveMatchConditionEvalError records validating or mutating webhook that are not called due to match conditions
func (m *AdmissionMetrics) ObserveMatchConditionEvalError(ctx context.Context, name, stepType string) {
m.matchConditionEvalErrors.WithContext(ctx).WithLabelValues(name, stepType).Inc()
}
type metricSet struct {
latencies *metrics.HistogramVec
latenciesSummary *metrics.SummaryVec

View File

@ -29,8 +29,6 @@ import (
"k8s.io/apiserver/pkg/authorization/authorizer"
)
var _ ExpressionAccessor = &MatchCondition{}
type ExpressionAccessor interface {
GetExpression() string
ReturnTypes() []*cel.Type
@ -44,19 +42,6 @@ type EvaluationResult struct {
Error error
}
// MatchCondition contains the inputs needed to compile, evaluate and match a cel expression
type MatchCondition struct {
Expression string
}
func (v *MatchCondition) GetExpression() string {
return v.Expression
}
func (v *MatchCondition) ReturnTypes() []*cel.Type {
return []*cel.Type{cel.BoolType}
}
// OptionalVariableDeclarations declares which optional CEL variables
// are declared for an expression.
type OptionalVariableDeclarations struct {

View File

@ -23,8 +23,6 @@ import (
celtypes "github.com/google/cel-go/common/types"
"k8s.io/klog/v2"
v1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@ -33,6 +31,7 @@ import (
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/authorization/authorizer"
apiservercel "k8s.io/apiserver/pkg/cel"
"k8s.io/klog/v2"
)
// validator implements the Validator interface

View File

@ -19,11 +19,15 @@ package webhook
import (
"sync"
"k8s.io/api/admissionregistration/v1"
v1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apiserver/pkg/admission/plugin/cel"
"k8s.io/apiserver/pkg/admission/plugin/webhook/matchconditions"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/namespace"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/object"
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/authorization/authorizer"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
"k8s.io/client-go/rest"
)
@ -44,6 +48,9 @@ type WebhookAccessor interface {
// GetRESTClient gets the webhook client
GetRESTClient(clientManager *webhookutil.ClientManager) (*rest.RESTClient, error)
// GetCompiledMatcher gets the compiled matcher object
GetCompiledMatcher(compiler cel.FilterCompiler, authorizer authorizer.Authorizer) matchconditions.Matcher
// GetName gets the webhook Name field. Note that the name is scoped to the webhook
// configuration and does not provide a globally unique identity, if a unique identity is
// needed, use GetUID.
@ -67,6 +74,9 @@ type WebhookAccessor interface {
// GetAdmissionReviewVersions gets the webhook AdmissionReviewVersions field.
GetAdmissionReviewVersions() []string
// GetMatchConditions gets the webhook match conditions field.
GetMatchConditions() []v1.MatchCondition
// GetMutatingWebhook if the accessor contains a MutatingWebhook, returns it and true, else returns false.
GetMutatingWebhook() (*v1.MutatingWebhook, bool)
// GetValidatingWebhook if the accessor contains a ValidatingWebhook, returns it and true, else returns false.
@ -94,6 +104,9 @@ type mutatingWebhookAccessor struct {
initClient sync.Once
client *rest.RESTClient
clientErr error
compileMatcher sync.Once
compiledMatcher matchconditions.Matcher
}
func (m *mutatingWebhookAccessor) GetUID() string {
@ -111,6 +124,28 @@ func (m *mutatingWebhookAccessor) GetRESTClient(clientManager *webhookutil.Clien
return m.client, m.clientErr
}
// TODO: graduation to beta: resolve the fact that we rebuild ALL items whenever ANY config changes in NewMutatingWebhookConfigurationManager and NewValidatingWebhookConfigurationManager ... now that we're doing CEL compilation, we probably want to avoid that
func (m *mutatingWebhookAccessor) GetCompiledMatcher(compiler cel.FilterCompiler, authorizer authorizer.Authorizer) matchconditions.Matcher {
m.compileMatcher.Do(func() {
expressions := make([]cel.ExpressionAccessor, len(m.MutatingWebhook.MatchConditions))
for i, matchCondition := range m.MutatingWebhook.MatchConditions {
expressions[i] = &matchconditions.MatchCondition{
Name: matchCondition.Name,
Expression: matchCondition.Expression,
}
}
m.compiledMatcher = matchconditions.NewMatcher(compiler.Compile(
expressions,
cel.OptionalVariableDeclarations{
HasParams: false,
HasAuthorizer: true,
},
celconfig.PerCallLimit,
), authorizer, m.FailurePolicy, "validating", m.Name)
})
return m.compiledMatcher
}
func (m *mutatingWebhookAccessor) GetParsedNamespaceSelector() (labels.Selector, error) {
m.initNamespaceSelector.Do(func() {
m.namespaceSelector, m.namespaceSelectorErr = metav1.LabelSelectorAsSelector(m.NamespaceSelector)
@ -165,6 +200,10 @@ func (m *mutatingWebhookAccessor) GetAdmissionReviewVersions() []string {
return m.AdmissionReviewVersions
}
func (m *mutatingWebhookAccessor) GetMatchConditions() []v1.MatchCondition {
return m.MatchConditions
}
func (m *mutatingWebhookAccessor) GetMutatingWebhook() (*v1.MutatingWebhook, bool) {
return m.MutatingWebhook, true
}
@ -194,6 +233,9 @@ type validatingWebhookAccessor struct {
initClient sync.Once
client *rest.RESTClient
clientErr error
compileMatcher sync.Once
compiledMatcher matchconditions.Matcher
}
func (v *validatingWebhookAccessor) GetUID() string {
@ -211,6 +253,27 @@ func (v *validatingWebhookAccessor) GetRESTClient(clientManager *webhookutil.Cli
return v.client, v.clientErr
}
func (v *validatingWebhookAccessor) GetCompiledMatcher(compiler cel.FilterCompiler, authorizer authorizer.Authorizer) matchconditions.Matcher {
v.compileMatcher.Do(func() {
expressions := make([]cel.ExpressionAccessor, len(v.ValidatingWebhook.MatchConditions))
for i, matchCondition := range v.ValidatingWebhook.MatchConditions {
expressions[i] = &matchconditions.MatchCondition{
Name: matchCondition.Name,
Expression: matchCondition.Expression,
}
}
v.compiledMatcher = matchconditions.NewMatcher(compiler.Compile(
expressions,
cel.OptionalVariableDeclarations{
HasParams: false,
HasAuthorizer: true,
},
celconfig.PerCallLimit,
), authorizer, v.FailurePolicy, "validating", v.Name)
})
return v.compiledMatcher
}
func (v *validatingWebhookAccessor) GetParsedNamespaceSelector() (labels.Selector, error) {
v.initNamespaceSelector.Do(func() {
v.namespaceSelector, v.namespaceSelectorErr = metav1.LabelSelectorAsSelector(v.NamespaceSelector)
@ -265,6 +328,10 @@ func (v *validatingWebhookAccessor) GetAdmissionReviewVersions() []string {
return v.AdmissionReviewVersions
}
func (v *validatingWebhookAccessor) GetMatchConditions() []v1.MatchCondition {
return v.MatchConditions
}
func (v *validatingWebhookAccessor) GetMutatingWebhook() (*v1.MutatingWebhook, bool) {
return nil, false
}

View File

@ -62,6 +62,7 @@ func TestMutatingWebhookAccessor(t *testing.T) {
SideEffects: accessor.GetSideEffects(),
TimeoutSeconds: accessor.GetTimeoutSeconds(),
AdmissionReviewVersions: accessor.GetAdmissionReviewVersions(),
MatchConditions: accessor.GetMatchConditions(),
}
if !reflect.DeepEqual(orig, copy) {
t.Errorf("expected mutatingWebhook to round trip through WebhookAccessor, diff:\n%s", diff.ObjectReflectDiff(orig, copy))
@ -102,6 +103,7 @@ func TestValidatingWebhookAccessor(t *testing.T) {
SideEffects: accessor.GetSideEffects(),
TimeoutSeconds: accessor.GetTimeoutSeconds(),
AdmissionReviewVersions: accessor.GetAdmissionReviewVersions(),
MatchConditions: accessor.GetMatchConditions(),
}
if !reflect.DeepEqual(orig, copy) {
t.Errorf("expected validatingWebhook to round trip through WebhookAccessor, diff:\n%s", diff.ObjectReflectDiff(orig, copy))

View File

@ -24,6 +24,10 @@ import (
"k8s.io/apiserver/pkg/admission/plugin/webhook"
)
type VersionedAttributeAccessor interface {
VersionedAttribute(gvk schema.GroupVersionKind) (*admission.VersionedAttributes, error)
}
// Source can list dynamic webhook plugins.
type Source interface {
Webhooks() []webhook.WebhookAccessor

View File

@ -23,19 +23,22 @@ import (
admissionv1 "k8s.io/api/admission/v1"
admissionv1beta1 "k8s.io/api/admission/v1beta1"
"k8s.io/api/admissionregistration/v1"
v1 "k8s.io/api/admissionregistration/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer"
"k8s.io/apiserver/pkg/admission/plugin/cel"
"k8s.io/apiserver/pkg/admission/plugin/webhook"
"k8s.io/apiserver/pkg/admission/plugin/webhook/config"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/namespace"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/object"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/rules"
"k8s.io/apiserver/pkg/authorization/authorizer"
webhookutil "k8s.io/apiserver/pkg/util/webhook"
"k8s.io/client-go/informers"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/klog/v2"
)
// Webhook is an abstract admission plugin with all the infrastructure to define Admit or Validate on-top.
@ -49,6 +52,8 @@ type Webhook struct {
namespaceMatcher *namespace.Matcher
objectMatcher *object.Matcher
dispatcher Dispatcher
filterCompiler cel.FilterCompiler
authorizer authorizer.Authorizer
}
var (
@ -92,6 +97,7 @@ func NewWebhook(handler *admission.Handler, configFile io.Reader, sourceFactory
namespaceMatcher: &namespace.Matcher{},
objectMatcher: &object.Matcher{},
dispatcher: dispatcherFactory(&cm),
filterCompiler: cel.NewFilterCompiler(),
}, nil
}
@ -124,6 +130,10 @@ func (a *Webhook) SetExternalKubeInformerFactory(f informers.SharedInformerFacto
})
}
func (a *Webhook) SetAuthorizer(authorizer authorizer.Authorizer) {
a.authorizer = authorizer
}
// ValidateInitialization implements the InitializationValidator interface.
func (a *Webhook) ValidateInitialization() error {
if a.hookSource == nil {
@ -140,7 +150,7 @@ func (a *Webhook) ValidateInitialization() error {
// ShouldCallHook returns invocation details if the webhook should be called, nil if the webhook should not be called,
// or an error if an error was encountered during evaluation.
func (a *Webhook) ShouldCallHook(h webhook.WebhookAccessor, attr admission.Attributes, o admission.ObjectInterfaces) (*WebhookInvocation, *apierrors.StatusError) {
func (a *Webhook) ShouldCallHook(ctx context.Context, h webhook.WebhookAccessor, attr admission.Attributes, o admission.ObjectInterfaces, v VersionedAttributeAccessor) (*WebhookInvocation, *apierrors.StatusError) {
matches, matchNsErr := a.namespaceMatcher.MatchNamespaceSelector(h, attr)
// Should not return an error here for webhooks which do not apply to the request, even if err is an unexpected scenario.
if !matches && matchNsErr == nil {
@ -207,6 +217,25 @@ func (a *Webhook) ShouldCallHook(h webhook.WebhookAccessor, attr admission.Attri
return nil, matchObjErr
}
matchConditions := h.GetMatchConditions()
if len(matchConditions) > 0 {
versionedAttr, err := v.VersionedAttribute(invocation.Kind)
if err != nil {
return nil, apierrors.NewInternalError(err)
}
matcher := h.GetCompiledMatcher(a.filterCompiler, a.authorizer)
matchResult := matcher.Match(ctx, versionedAttr, nil)
if matchResult.Error != nil {
klog.Warningf("Failed evaluating match conditions, failing closed %v: %v", h.GetName(), matchResult.Error)
return nil, apierrors.NewForbidden(attr.GetResource().GroupResource(), attr.GetName(), matchResult.Error)
} else if !matchResult.Matches {
// if no match, always skip webhook
return nil, nil
}
}
return invocation, nil
}

View File

@ -17,21 +17,26 @@ limitations under the License.
package generic
import (
"context"
"errors"
"fmt"
"strings"
"testing"
v1 "k8s.io/api/admissionregistration/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
k8serrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/admission/plugin/cel"
"k8s.io/apiserver/pkg/admission/plugin/webhook"
"k8s.io/apiserver/pkg/admission/plugin/webhook/matchconditions"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/namespace"
"k8s.io/apiserver/pkg/admission/plugin/webhook/predicates/object"
"k8s.io/apiserver/pkg/authorization/authorizer"
)
func gvr(group, version, resource string) schema.GroupVersionResource {
@ -42,8 +47,55 @@ func gvk(group, version, kind string) schema.GroupVersionKind {
return schema.GroupVersionKind{Group: group, Version: version, Kind: kind}
}
var _ matchconditions.Matcher = &fakeMatcher{}
type fakeMatcher struct {
throwError error
matchResult bool
}
func (f *fakeMatcher) Match(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object) matchconditions.MatchResult {
if f.throwError != nil {
return matchconditions.MatchResult{
Matches: true,
FailedConditionName: "",
Error: f.throwError,
}
}
return matchconditions.MatchResult{
Matches: f.matchResult,
FailedConditionName: "",
}
}
var _ webhook.WebhookAccessor = &fakeWebhookAccessor{}
type fakeWebhookAccessor struct {
webhook.WebhookAccessor
throwError error
matchResult bool
}
func (f *fakeWebhookAccessor) GetCompiledMatcher(compiler cel.FilterCompiler, authorizer authorizer.Authorizer) matchconditions.Matcher {
return &fakeMatcher{
throwError: f.throwError,
matchResult: f.matchResult,
}
}
var _ VersionedAttributeAccessor = &fakeVersionedAttributeAccessor{}
type fakeVersionedAttributeAccessor struct{}
func (v *fakeVersionedAttributeAccessor) VersionedAttribute(gvk schema.GroupVersionKind) (*admission.VersionedAttributes, error) {
return nil, nil
}
func TestShouldCallHook(t *testing.T) {
a := &Webhook{namespaceMatcher: &namespace.Matcher{}, objectMatcher: &object.Matcher{}}
a := &Webhook{
namespaceMatcher: &namespace.Matcher{},
objectMatcher: &object.Matcher{},
}
allScopes := v1.AllScopes
exactMatch := v1.Exact
@ -83,12 +135,15 @@ func TestShouldCallHook(t *testing.T) {
expectCallResource schema.GroupVersionResource
expectCallSubresource string
expectCallKind schema.GroupVersionKind
matchError error
matchResult bool
}{
{
name: "no rules (just write)",
webhook: &v1.ValidatingWebhook{NamespaceSelector: &metav1.LabelSelector{}, Rules: []v1.RuleWithOperations{}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
name: "no rules (just write)",
webhook: &v1.ValidatingWebhook{NamespaceSelector: &metav1.LabelSelector{}, Rules: []v1.RuleWithOperations{}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: true,
},
{
name: "invalid kind lookup",
@ -100,9 +155,10 @@ func TestShouldCallHook(t *testing.T) {
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"example.com"}, APIVersions: []string{"v1"}, Resources: []string{"widgets"}, Scope: &allScopes},
}}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("example.com", "v2", "Widget"), "ns", "name", gvr("example.com", "v2", "widgets"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
expectErr: "unknown kind",
attrs: admission.NewAttributesRecord(nil, nil, gvk("example.com", "v2", "Widget"), "ns", "name", gvr("example.com", "v2", "widgets"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
expectErr: "unknown kind",
matchResult: true,
},
{
name: "wildcard rule, match as requested",
@ -118,6 +174,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("apps", "v1", "Deployment"),
expectCallResource: gvr("apps", "v1", "deployments"),
expectCallSubresource: "",
matchResult: true,
},
{
name: "specific rules, prefer exact match",
@ -139,6 +196,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("apps", "v1", "Deployment"),
expectCallResource: gvr("apps", "v1", "deployments"),
expectCallSubresource: "",
matchResult: true,
},
{
name: "specific rules, match miss",
@ -152,8 +210,9 @@ func TestShouldCallHook(t *testing.T) {
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"apps"}, APIVersions: []string{"v1beta1"}, Resources: []string{"deployments"}, Scope: &allScopes},
}}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: true,
},
{
name: "specific rules, exact match miss",
@ -168,8 +227,9 @@ func TestShouldCallHook(t *testing.T) {
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"apps"}, APIVersions: []string{"v1beta1"}, Resources: []string{"deployments"}, Scope: &allScopes},
}}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: true,
},
{
name: "specific rules, equivalent match, prefer extensions",
@ -189,6 +249,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("extensions", "v1beta1", "Deployment"),
expectCallResource: gvr("extensions", "v1beta1", "deployments"),
expectCallSubresource: "",
matchResult: true,
},
{
name: "specific rules, equivalent match, prefer apps",
@ -208,6 +269,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("apps", "v1beta1", "Deployment"),
expectCallResource: gvr("apps", "v1beta1", "deployments"),
expectCallSubresource: "",
matchResult: true,
},
{
@ -230,6 +292,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("autoscaling", "v1", "Scale"),
expectCallResource: gvr("apps", "v1", "deployments"),
expectCallSubresource: "scale",
matchResult: true,
},
{
name: "specific rules, subresource match miss",
@ -243,8 +306,9 @@ func TestShouldCallHook(t *testing.T) {
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"apps"}, APIVersions: []string{"v1beta1"}, Resources: []string{"deployments", "deployments/scale"}, Scope: &allScopes},
}}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
attrs: admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: true,
},
{
name: "specific rules, subresource exact match miss",
@ -259,8 +323,9 @@ func TestShouldCallHook(t *testing.T) {
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"apps"}, APIVersions: []string{"v1beta1"}, Resources: []string{"deployments", "deployments/scale"}, Scope: &allScopes},
}}},
attrs: admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
attrs: admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: true,
},
{
name: "specific rules, subresource equivalent match, prefer extensions",
@ -280,6 +345,7 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("extensions", "v1beta1", "Scale"),
expectCallResource: gvr("extensions", "v1beta1", "deployments"),
expectCallSubresource: "scale",
matchResult: true,
},
{
name: "specific rules, subresource equivalent match, prefer apps",
@ -299,12 +365,86 @@ func TestShouldCallHook(t *testing.T) {
expectCallKind: gvk("apps", "v1beta1", "Scale"),
expectCallResource: gvr("apps", "v1beta1", "deployments"),
expectCallSubresource: "scale",
matchResult: true,
},
{
name: "wildcard rule, match conditions also match",
webhook: &v1.ValidatingWebhook{
NamespaceSelector: &metav1.LabelSelector{},
ObjectSelector: &metav1.LabelSelector{},
Rules: []v1.RuleWithOperations{{
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*"}, Scope: &allScopes},
}},
MatchConditions: []v1.MatchCondition{
{
Name: "test1",
Expression: "test expression",
},
},
},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: true,
expectCallKind: gvk("apps", "v1", "Deployment"),
expectCallResource: gvr("apps", "v1", "deployments"),
expectCallSubresource: "",
matchResult: true,
},
{
name: "wildcard rule, match conditions do not match",
webhook: &v1.ValidatingWebhook{
NamespaceSelector: &metav1.LabelSelector{},
ObjectSelector: &metav1.LabelSelector{},
Rules: []v1.RuleWithOperations{{
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*"}, Scope: &allScopes},
}},
MatchConditions: []v1.MatchCondition{
{
Name: "test1",
Expression: "test expression",
},
},
},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
matchResult: false,
},
{
name: "wildcard rule, match conditions error",
webhook: &v1.ValidatingWebhook{
NamespaceSelector: &metav1.LabelSelector{},
ObjectSelector: &metav1.LabelSelector{},
Rules: []v1.RuleWithOperations{{
Operations: []v1.OperationType{"*"},
Rule: v1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*"}, Scope: &allScopes},
}},
MatchConditions: []v1.MatchCondition{
{
Name: "test1",
Expression: "test expression",
},
},
},
attrs: admission.NewAttributesRecord(nil, nil, gvk("apps", "v1", "Deployment"), "ns", "name", gvr("apps", "v1", "deployments"), "", admission.Create, &metav1.CreateOptions{}, false, nil),
expectCall: false,
expectErr: "deployments.apps \"name\" is forbidden: test error",
expectCallKind: gvk("apps", "v1", "Deployment"),
expectCallResource: gvr("apps", "v1", "deployments"),
expectCallSubresource: "",
matchError: errors.New("test error"),
},
}
for i, testcase := range testcases {
t.Run(testcase.name, func(t *testing.T) {
invocation, err := a.ShouldCallHook(webhook.NewValidatingWebhookAccessor(fmt.Sprintf("webhook-%d", i), fmt.Sprintf("webhook-cfg-%d", i), testcase.webhook), testcase.attrs, interfaces)
fakeWebhook := &fakeWebhookAccessor{
WebhookAccessor: webhook.NewValidatingWebhookAccessor(fmt.Sprintf("webhook-%d", i), fmt.Sprintf("webhook-cfg-%d", i), testcase.webhook),
matchResult: testcase.matchResult,
throwError: testcase.matchError,
}
invocation, err := a.ShouldCallHook(context.TODO(), fakeWebhook, testcase.attrs, interfaces, &fakeVersionedAttributeAccessor{})
if err != nil {
if len(testcase.expectErr) == 0 {
t.Fatal(err)
@ -353,7 +493,7 @@ func (f fakeNamespaceLister) Get(name string) (*corev1.Namespace, error) {
if ok {
return ns, nil
}
return nil, errors.NewNotFound(corev1.Resource("namespaces"), name)
return nil, k8serrors.NewNotFound(corev1.Resource("namespaces"), name)
}
func BenchmarkShouldCallHookWithComplexSelector(b *testing.B) {
@ -415,13 +555,16 @@ func BenchmarkShouldCallHookWithComplexSelector(b *testing.B) {
},
}
wbAccessor := webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb)
wbAccessor := &fakeWebhookAccessor{
WebhookAccessor: webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb),
matchResult: true,
}
attrs := admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil)
interfaces := &admission.RuntimeObjectInterfaces{EquivalentResourceMapper: mapper}
a := &Webhook{namespaceMatcher: &namespace.Matcher{NamespaceLister: namespaceLister}, objectMatcher: &object.Matcher{}}
for i := 0; i < b.N; i++ {
a.ShouldCallHook(wbAccessor, attrs, interfaces)
a.ShouldCallHook(context.TODO(), wbAccessor, attrs, interfaces, nil)
}
}
@ -483,13 +626,16 @@ func BenchmarkShouldCallHookWithComplexRule(b *testing.B) {
wb.Rules = append(wb.Rules, rule)
}
wbAccessor := webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb)
wbAccessor := &fakeWebhookAccessor{
WebhookAccessor: webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb),
matchResult: true,
}
attrs := admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil)
interfaces := &admission.RuntimeObjectInterfaces{EquivalentResourceMapper: mapper}
a := &Webhook{namespaceMatcher: &namespace.Matcher{NamespaceLister: namespaceLister}, objectMatcher: &object.Matcher{}}
for i := 0; i < b.N; i++ {
a.ShouldCallHook(wbAccessor, attrs, interfaces)
a.ShouldCallHook(context.TODO(), wbAccessor, attrs, interfaces, &fakeVersionedAttributeAccessor{})
}
}
@ -556,12 +702,15 @@ func BenchmarkShouldCallHookWithComplexSelectorAndRule(b *testing.B) {
wb.Rules = append(wb.Rules, rule)
}
wbAccessor := webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb)
wbAccessor := &fakeWebhookAccessor{
WebhookAccessor: webhook.NewValidatingWebhookAccessor("webhook", "webhook-cfg", wb),
matchResult: true,
}
attrs := admission.NewAttributesRecord(nil, nil, gvk("autoscaling", "v1", "Scale"), "ns", "name", gvr("apps", "v1", "deployments"), "scale", admission.Create, &metav1.CreateOptions{}, false, nil)
interfaces := &admission.RuntimeObjectInterfaces{EquivalentResourceMapper: mapper}
a := &Webhook{namespaceMatcher: &namespace.Matcher{NamespaceLister: namespaceLister}, objectMatcher: &object.Matcher{}}
for i := 0; i < b.N; i++ {
a.ShouldCallHook(wbAccessor, attrs, interfaces)
a.ShouldCallHook(context.TODO(), wbAccessor, attrs, interfaces, nil)
}
}

View File

@ -0,0 +1,36 @@
/*
Copyright 2023 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 matchconditions
import (
"context"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/admission"
)
type MatchResult struct {
Matches bool
Error error
FailedConditionName string
}
// Matcher contains logic for converting Evaluations to bool of matches or does not match
type Matcher interface {
// Match is used to take cel evaluations and convert into decisions
Match(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object) MatchResult
}

View File

@ -0,0 +1,139 @@
/*
Copyright 2023 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 matchconditions
import (
"context"
"errors"
"fmt"
"github.com/google/cel-go/cel"
celtypes "github.com/google/cel-go/common/types"
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/runtime"
utilerrors "k8s.io/apimachinery/pkg/util/errors"
"k8s.io/apiserver/pkg/admission"
admissionmetrics "k8s.io/apiserver/pkg/admission/metrics"
celplugin "k8s.io/apiserver/pkg/admission/plugin/cel"
celconfig "k8s.io/apiserver/pkg/apis/cel"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/klog/v2"
)
var _ celplugin.ExpressionAccessor = &MatchCondition{}
// MatchCondition contains the inputs needed to compile, evaluate and match a cel expression
type MatchCondition v1.MatchCondition
func (v *MatchCondition) GetExpression() string {
return v.Expression
}
func (v *MatchCondition) ReturnTypes() []*cel.Type {
return []*cel.Type{cel.BoolType}
}
var _ Matcher = &matcher{}
// matcher evaluates compiled cel expressions and determines if they match the given request or not
type matcher struct {
filter celplugin.Filter
authorizer authorizer.Authorizer
failPolicy v1.FailurePolicyType
matcherType string
objectName string
}
func NewMatcher(filter celplugin.Filter, authorizer authorizer.Authorizer, failPolicy *v1.FailurePolicyType, matcherType, objectName string) Matcher {
var f v1.FailurePolicyType
if failPolicy == nil {
f = v1.Fail
} else {
f = *failPolicy
}
return &matcher{
filter: filter,
authorizer: authorizer,
failPolicy: f,
matcherType: matcherType,
objectName: objectName,
}
}
func (m *matcher) Match(ctx context.Context, versionedAttr *admission.VersionedAttributes, versionedParams runtime.Object) MatchResult {
evalResults, _, err := m.filter.ForInput(ctx, versionedAttr, celplugin.CreateAdmissionRequest(versionedAttr.Attributes), celplugin.OptionalVariableBindings{
VersionedParams: versionedParams,
Authorizer: m.authorizer,
}, celconfig.RuntimeCELCostBudgetMatchConditions)
if err != nil {
// filter returning error is unexpected and not an evaluation error so not incrementing metric here
if m.failPolicy == v1.Fail {
return MatchResult{
Error: err,
}
} else if m.failPolicy == v1.Ignore {
return MatchResult{
Matches: false,
}
}
//TODO: add default so that if in future we add different failure types it doesn't fall through
}
errorList := []error{}
for _, evalResult := range evalResults {
matchCondition, ok := evalResult.ExpressionAccessor.(*MatchCondition)
if !ok {
// This shouldnt happen, but if it does treat same as eval error
klog.Error("Invalid type conversion to MatchCondition")
errorList = append(errorList, errors.New(fmt.Sprintf("internal error converting ExpressionAccessor to MatchCondition")))
continue
}
if evalResult.Error != nil {
errorList = append(errorList, evalResult.Error)
//TODO: what's the best way to handle this metric since its reused by VAP for match conditions
admissionmetrics.Metrics.ObserveMatchConditionEvalError(ctx, m.objectName, m.matcherType)
}
if evalResult.EvalResult == celtypes.False {
// If any condition false, skip calling webhook always
return MatchResult{
Matches: false,
FailedConditionName: matchCondition.Name,
}
}
}
if len(errorList) > 0 {
// If mix of true and eval errors then resort to fail policy
if m.failPolicy == v1.Fail {
// mix of true and errors with fail policy fail should fail request without calling webhook
err = utilerrors.NewAggregate(errorList)
return MatchResult{
Error: err,
}
} else if m.failPolicy == v1.Ignore {
// if fail policy ignore then skip call to webhook
return MatchResult{
Matches: false,
}
}
}
// if no results eval to false, return matches true with list of any errors encountered
return MatchResult{
Matches: true,
}
}

View File

@ -0,0 +1,360 @@
/*
Copyright 2023 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 matchconditions
import (
"context"
"errors"
"strings"
"testing"
v1 "k8s.io/api/admissionregistration/v1"
celtypes "github.com/google/cel-go/common/types"
"github.com/stretchr/testify/require"
admissionv1 "k8s.io/api/admission/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/admission/plugin/cel"
)
var _ cel.Filter = &fakeCelFilter{}
type fakeCelFilter struct {
evaluations []cel.EvaluationResult
throwError bool
}
func (f *fakeCelFilter) ForInput(context.Context, *admission.VersionedAttributes, *admissionv1.AdmissionRequest, cel.OptionalVariableBindings, int64) ([]cel.EvaluationResult, int64, error) {
if f.throwError {
return nil, 0, errors.New("test error")
}
return f.evaluations, 0, nil
}
func (f *fakeCelFilter) CompilationErrors() []error {
return []error{}
}
func TestMatch(t *testing.T) {
fakeAttr := admission.NewAttributesRecord(nil, nil, schema.GroupVersionKind{}, "default", "foo", schema.GroupVersionResource{}, "", admission.Create, nil, false, nil)
fakeVersionedAttr, _ := admission.NewVersionedAttributes(fakeAttr, schema.GroupVersionKind{}, nil)
fail := v1.Fail
ignore := v1.Ignore
cases := []struct {
name string
evaluations []cel.EvaluationResult
throwError bool
shouldMatch bool
returnedName string
failPolicy *v1.FailurePolicyType
expectError string
}{
{
name: "test single matches",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
},
shouldMatch: true,
},
{
name: "test multiple match",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
},
shouldMatch: true,
},
{
name: "test empty evals",
evaluations: []cel.EvaluationResult{},
shouldMatch: true,
},
{
name: "test single no match",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test1",
},
},
},
shouldMatch: false,
returnedName: "test1",
},
{
name: "test multiple no match",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test1",
},
},
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test2",
},
},
},
shouldMatch: false,
returnedName: "test1",
},
{
name: "test mixed with no match first",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test1",
},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{
Name: "test2",
},
},
},
shouldMatch: false,
returnedName: "test1",
},
{
name: "test mixed with no match last",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{
Name: "test2",
},
},
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test1",
},
},
},
shouldMatch: false,
returnedName: "test1",
},
{
name: "test mixed with no match middle",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{
Name: "test2",
},
},
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{
Name: "test1",
},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{
Name: "test2",
},
},
},
shouldMatch: false,
returnedName: "test1",
},
{
name: "test error, no fail policy",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
},
shouldMatch: true,
throwError: true,
expectError: "test error",
},
{
name: "test error, fail policy fail",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
},
failPolicy: &fail,
shouldMatch: true,
throwError: true,
expectError: "test error",
},
{
name: "test error, fail policy ignore",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
},
failPolicy: &ignore,
shouldMatch: false,
throwError: true,
},
{
name: "test mix of true, errors and false",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.False,
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
},
shouldMatch: false,
throwError: false,
},
{
name: "test mix of true, errors and fail policy not set",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
},
shouldMatch: false,
throwError: false,
expectError: "test error",
},
{
name: "test mix of true, errors and fail policy fail",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
},
failPolicy: &fail,
shouldMatch: false,
throwError: false,
expectError: "test error",
},
{
name: "test mix of true, errors and fail policy ignore",
evaluations: []cel.EvaluationResult{
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
{
EvalResult: celtypes.True,
ExpressionAccessor: &MatchCondition{},
},
{
Error: errors.New("test error"),
ExpressionAccessor: &MatchCondition{},
},
},
failPolicy: &ignore,
shouldMatch: false,
throwError: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := NewMatcher(&fakeCelFilter{
evaluations: tc.evaluations,
throwError: tc.throwError,
}, nil, tc.failPolicy, "test", "testhook")
ctx := context.TODO()
matchResult := m.Match(ctx, fakeVersionedAttr, nil)
if matchResult.Error != nil {
if len(tc.expectError) == 0 {
t.Fatal(matchResult.Error)
}
if !strings.Contains(matchResult.Error.Error(), tc.expectError) {
t.Fatalf("expected error containing %q, got %s", tc.expectError, matchResult.Error.Error())
}
return
} else if len(tc.expectError) > 0 {
t.Fatal("expected error but did not get one")
}
if len(tc.expectError) > 0 && matchResult.Error == nil {
t.Errorf("expected error thrown when filter errors")
}
require.Equal(t, tc.shouldMatch, matchResult.Matches)
require.Equal(t, tc.returnedName, matchResult.FailedConditionName)
})
}
}

View File

@ -26,14 +26,13 @@ import (
jsonpatch "github.com/evanphx/json-patch"
"go.opentelemetry.io/otel/attribute"
apiequality "k8s.io/apimachinery/pkg/api/equality"
"k8s.io/klog/v2"
admissionv1 "k8s.io/api/admission/v1"
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
apiequality "k8s.io/apimachinery/pkg/api/equality"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer/json"
utiljson "k8s.io/apimachinery/pkg/util/json"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
@ -48,6 +47,7 @@ import (
webhookutil "k8s.io/apiserver/pkg/util/webhook"
"k8s.io/apiserver/pkg/warning"
"k8s.io/component-base/tracing"
"k8s.io/klog/v2"
)
const (
@ -75,6 +75,30 @@ func newMutatingDispatcher(p *Plugin) func(cm *webhookutil.ClientManager) generi
}
}
var _ generic.VersionedAttributeAccessor = &versionedAttributeAccessor{}
type versionedAttributeAccessor struct {
versionedAttr *admission.VersionedAttributes
attr admission.Attributes
objectInterfaces admission.ObjectInterfaces
}
func (v *versionedAttributeAccessor) VersionedAttribute(gvk schema.GroupVersionKind) (*admission.VersionedAttributes, error) {
if v.versionedAttr == nil {
// First call, create versioned attributes
var err error
if v.versionedAttr, err = admission.NewVersionedAttributes(v.attr, gvk, v.objectInterfaces); err != nil {
return nil, apierrors.NewInternalError(err)
}
} else {
// Subsequent call, convert existing versioned attributes to the requested version
if err := admission.ConvertVersionedAttributes(v.versionedAttr, gvk, v.objectInterfaces); err != nil {
return nil, apierrors.NewInternalError(err)
}
}
return v.versionedAttr, nil
}
var _ generic.Dispatcher = &mutatingDispatcher{}
func (a *mutatingDispatcher) Dispatch(ctx context.Context, attr admission.Attributes, o admission.ObjectInterfaces, hooks []webhook.WebhookAccessor) error {
@ -95,19 +119,24 @@ func (a *mutatingDispatcher) Dispatch(ctx context.Context, attr admission.Attrib
defer func() {
webhookReinvokeCtx.SetLastWebhookInvocationOutput(attr.GetObject())
}()
var versionedAttr *admission.VersionedAttributes
v := &versionedAttributeAccessor{
attr: attr,
objectInterfaces: o,
}
for i, hook := range hooks {
attrForCheck := attr
if versionedAttr != nil {
attrForCheck = versionedAttr
if v.versionedAttr != nil {
attrForCheck = v.versionedAttr
}
invocation, statusErr := a.plugin.ShouldCallHook(hook, attrForCheck, o)
invocation, statusErr := a.plugin.ShouldCallHook(ctx, hook, attrForCheck, o, v)
if statusErr != nil {
return statusErr
}
if invocation == nil {
continue
}
hook, ok := invocation.Webhook.GetMutatingWebhook()
if !ok {
return fmt.Errorf("mutating webhook dispatch requires v1.MutatingWebhook, but got %T", hook)
@ -121,17 +150,9 @@ func (a *mutatingDispatcher) Dispatch(ctx context.Context, attr admission.Attrib
continue
}
if versionedAttr == nil {
// First webhook, create versioned attributes
var err error
if versionedAttr, err = admission.NewVersionedAttributes(attr, invocation.Kind, o); err != nil {
return apierrors.NewInternalError(err)
}
} else {
// Subsequent webhook, convert existing versioned attributes to this webhook's version
if err := admission.ConvertVersionedAttributes(versionedAttr, invocation.Kind, o); err != nil {
return apierrors.NewInternalError(err)
}
versionedAttr, err := v.VersionedAttribute(invocation.Kind)
if err != nil {
return apierrors.NewInternalError(err)
}
t := time.Now()
@ -203,8 +224,8 @@ func (a *mutatingDispatcher) Dispatch(ctx context.Context, attr admission.Attrib
}
// convert versionedAttr.VersionedObject to the internal version in the underlying admission.Attributes
if versionedAttr != nil && versionedAttr.VersionedObject != nil && versionedAttr.Dirty {
return o.GetObjectConvertor().Convert(versionedAttr.VersionedObject, versionedAttr.Attributes.GetObject(), nil)
if v.versionedAttr != nil && v.versionedAttr.VersionedObject != nil && v.versionedAttr.Dirty {
return o.GetObjectConvertor().Convert(v.versionedAttr.VersionedObject, v.versionedAttr.Attributes.GetObject(), nil)
}
return nil

View File

@ -62,30 +62,51 @@ func newValidatingDispatcher(p *Plugin) func(cm *webhookutil.ClientManager) gene
}
}
var _ generic.VersionedAttributeAccessor = &versionedAttributeAccessor{}
type versionedAttributeAccessor struct {
versionedAttrs map[schema.GroupVersionKind]*admission.VersionedAttributes
attr admission.Attributes
objectInterfaces admission.ObjectInterfaces
}
func (v *versionedAttributeAccessor) VersionedAttribute(gvk schema.GroupVersionKind) (*admission.VersionedAttributes, error) {
if val, ok := v.versionedAttrs[gvk]; ok {
return val, nil
}
versionedAttr, err := admission.NewVersionedAttributes(v.attr, gvk, v.objectInterfaces)
if err != nil {
return nil, err
}
v.versionedAttrs[gvk] = versionedAttr
return versionedAttr, nil
}
var _ generic.Dispatcher = &validatingDispatcher{}
func (d *validatingDispatcher) Dispatch(ctx context.Context, attr admission.Attributes, o admission.ObjectInterfaces, hooks []webhook.WebhookAccessor) error {
var relevantHooks []*generic.WebhookInvocation
// Construct all the versions we need to call our webhooks
versionedAttrs := map[schema.GroupVersionKind]*admission.VersionedAttributes{}
versionedAttrAccessor := &versionedAttributeAccessor{
versionedAttrs: map[schema.GroupVersionKind]*admission.VersionedAttributes{},
attr: attr,
objectInterfaces: o,
}
for _, hook := range hooks {
invocation, statusError := d.plugin.ShouldCallHook(hook, attr, o)
invocation, statusError := d.plugin.ShouldCallHook(ctx, hook, attr, o, versionedAttrAccessor)
if statusError != nil {
return statusError
}
if invocation == nil {
continue
}
relevantHooks = append(relevantHooks, invocation)
// If we already have this version, continue
if _, ok := versionedAttrs[invocation.Kind]; ok {
continue
}
versionedAttr, err := admission.NewVersionedAttributes(attr, invocation.Kind, o)
// VersionedAttr result will be cached and reused later during parallel webhook calls
_, err := versionedAttrAccessor.VersionedAttribute(invocation.Kind)
if err != nil {
return apierrors.NewInternalError(err)
}
versionedAttrs[invocation.Kind] = versionedAttr
}
if len(relevantHooks) == 0 {
@ -108,7 +129,7 @@ func (d *validatingDispatcher) Dispatch(ctx context.Context, attr admission.Attr
go func(invocation *generic.WebhookInvocation, idx int) {
ignoreClientCallFailures := false
hookName := "unknown"
versionedAttr := versionedAttrs[invocation.Kind]
versionedAttr := versionedAttrAccessor.versionedAttrs[invocation.Kind]
// The ordering of these two defers is critical. The wg.Done will release the parent go func to close the errCh
// that is used by the second defer to report errors. The recovery and error reporting must be done first.
defer wg.Done()

View File

@ -25,6 +25,11 @@ const (
// current RuntimeCELCostBudget gives roughly 1 seconds for the validation
RuntimeCELCostBudget = 10000000
// RuntimeCELCostBudgetMatchConditions is the overall cost budget for runtime CEL validation cost on matchConditions per object with matchConditions
// this is per webhook for validatingwebhookconfigurations and mutatingwebhookconfigurations or per ValidatingAdmissionPolicyBinding
// current RuntimeCELCostBudgetMatchConditions gives roughly 1/4 seconds for the validation
RuntimeCELCostBudgetMatchConditions = 2500000
// CheckFrequency configures the number of iterations within a comprehension to evaluate
// before checking whether the function evaluation has been interrupted
CheckFrequency = 100

View File

@ -35,6 +35,13 @@ const (
// of code conflicts because changes are more likely to be scattered
// across the file.
// owner: @ivelichkovich, @tallclair
// alpha: v1.27
// kep: https://kep.k8s.io/3716
//
// Enables usage of MatchConditions fields to use CEL expressions for matching on admission webhooks
AdmissionWebhookMatchConditions featuregate.Feature = "AdmissionWebhookMatchConditions"
// owner: @jefftree @alexzielenski
// alpha: v1.26
//
@ -223,8 +230,11 @@ func init() {
// To add a new feature, define a key for it above and add it here. The features will be
// available throughout Kubernetes binaries.
var defaultKubernetesFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{
AggregatedDiscoveryEndpoint: {Default: true, PreRelease: featuregate.Beta},
AdmissionWebhookMatchConditions: {Default: false, PreRelease: featuregate.Alpha},
APIListChunking: {Default: true, PreRelease: featuregate.Beta},
APIPriorityAndFairness: {Default: true, PreRelease: featuregate.Beta},