apiserver/pkg/admission/plugin/cel/validator_test.go

573 lines
16 KiB
Go

/*
Copyright 2022 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 cel
import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"github.com/stretchr/testify/require"
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/api/admissionregistration/v1alpha1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
)
func TestCompile(t *testing.T) {
cases := []struct {
name string
policy *v1alpha1.ValidatingAdmissionPolicy
errorExpressions map[string]string
}{
{
name: "invalid syntax",
policy: &v1alpha1.ValidatingAdmissionPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Spec: v1alpha1.ValidatingAdmissionPolicySpec{
FailurePolicy: func() *v1alpha1.FailurePolicyType {
r := v1alpha1.FailurePolicyType("Fail")
return &r
}(),
ParamKind: &v1alpha1.ParamKind{
APIVersion: "rules.example.com/v1",
Kind: "ReplicaLimit",
},
Validations: []v1alpha1.Validation{
{
Expression: "1 < 'asdf'",
},
{
Expression: "1 < 2",
},
},
MatchConstraints: &v1alpha1.MatchResources{
MatchPolicy: func() *v1alpha1.MatchPolicyType {
r := v1alpha1.MatchPolicyType("Exact")
return &r
}(),
ResourceRules: []v1alpha1.NamedRuleWithOperations{
{
RuleWithOperations: v1alpha1.RuleWithOperations{
Operations: []v1.OperationType{"CREATE"},
Rule: v1.Rule{
APIGroups: []string{"a"},
APIVersions: []string{"a"},
Resources: []string{"a"},
},
},
},
},
ObjectSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
},
},
},
errorExpressions: map[string]string{
"1 < 'asdf'": "found no matching overload for '_<_' applied to '(int, string)",
},
},
{
name: "valid syntax",
policy: &v1alpha1.ValidatingAdmissionPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Spec: v1alpha1.ValidatingAdmissionPolicySpec{
FailurePolicy: func() *v1alpha1.FailurePolicyType {
r := v1alpha1.FailurePolicyType("Fail")
return &r
}(),
Validations: []v1alpha1.Validation{
{
Expression: "1 < 2",
},
{
Expression: "object.spec.string.matches('[0-9]+')",
},
{
Expression: "request.kind.group == 'example.com' && request.kind.version == 'v1' && request.kind.kind == 'Fake'",
},
},
MatchConstraints: &v1alpha1.MatchResources{
MatchPolicy: func() *v1alpha1.MatchPolicyType {
r := v1alpha1.MatchPolicyType("Exact")
return &r
}(),
ResourceRules: []v1alpha1.NamedRuleWithOperations{
{
RuleWithOperations: v1alpha1.RuleWithOperations{
Operations: []v1.OperationType{"CREATE"},
Rule: v1.Rule{
APIGroups: []string{"a"},
APIVersions: []string{"a"},
Resources: []string{"a"},
},
},
},
},
ObjectSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
},
},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var c CELValidatorCompiler
validator := c.Compile(tc.policy)
if validator == nil {
t.Fatalf("unexpected nil validator")
}
validations := tc.policy.Spec.Validations
CompilationResults := validator.(*CELValidator).compilationResults
require.Equal(t, len(validations), len(CompilationResults))
meets := make([]bool, len(validations))
for expr, expectErr := range tc.errorExpressions {
for i, result := range CompilationResults {
if validations[i].Expression == expr {
if result.Error == nil {
t.Errorf("Expect expression '%s' to contain error '%v' but got no error", expr, expectErr)
} else if !strings.Contains(result.Error.Error(), expectErr) {
t.Errorf("Expected validation '%s' error to contain '%v' but got: %v", expr, expectErr, result.Error)
}
meets[i] = true
}
}
}
for i, meet := range meets {
if !meet && CompilationResults[i].Error != nil {
t.Errorf("Unexpected err '%v' for expression '%s'", CompilationResults[i].Error, validations[i].Expression)
}
}
})
}
}
func getValidPolicy(validations []v1alpha1.Validation, params *v1alpha1.ParamKind, fp *v1alpha1.FailurePolicyType) *v1alpha1.ValidatingAdmissionPolicy {
if fp == nil {
fp = func() *v1alpha1.FailurePolicyType {
r := v1alpha1.FailurePolicyType("Fail")
return &r
}()
}
return &v1alpha1.ValidatingAdmissionPolicy{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Spec: v1alpha1.ValidatingAdmissionPolicySpec{
FailurePolicy: fp,
Validations: validations,
ParamKind: params,
MatchConstraints: &v1alpha1.MatchResources{
MatchPolicy: func() *v1alpha1.MatchPolicyType {
r := v1alpha1.MatchPolicyType("Exact")
return &r
}(),
ResourceRules: []v1alpha1.NamedRuleWithOperations{
{
RuleWithOperations: v1alpha1.RuleWithOperations{
Operations: []v1.OperationType{"CREATE"},
Rule: v1.Rule{
APIGroups: []string{"a"},
APIVersions: []string{"a"},
Resources: []string{"a"},
},
},
},
},
ObjectSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
NamespaceSelector: &metav1.LabelSelector{
MatchLabels: map[string]string{"a": "b"},
},
},
},
}
}
func generatedDecision(k policyDecisionKind, m string, r metav1.StatusReason) policyDecision {
return policyDecision{kind: k, message: m, reason: r}
}
func TestValidate(t *testing.T) {
// we fake the paramKind in ValidatingAdmissionPolicy for testing since the params is directly passed from cel admission
// Inside validator.go, we only check if paramKind exists
hasParamKind := &v1alpha1.ParamKind{
APIVersion: "v1",
Kind: "ConfigMap",
}
ignorePolicy := func() *v1alpha1.FailurePolicyType {
r := v1alpha1.FailurePolicyType("Ignore")
return &r
}()
forbiddenReason := func() *metav1.StatusReason {
r := metav1.StatusReasonForbidden
return &r
}()
configMapParams := &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Data: map[string]string{
"fakeString": "fake",
},
}
crdParams := &unstructured.Unstructured{
Object: map[string]interface{}{
"spec": map[string]interface{}{
"testSize": 10,
},
},
}
podObject := corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Spec: corev1.PodSpec{
NodeName: "testnode",
},
}
cases := []struct {
name string
policy *v1alpha1.ValidatingAdmissionPolicy
attributes admission.Attributes
params runtime.Object
policyDecisions []policyDecision
}{
{
name: "valid syntax for object",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "has(object.subsets) && object.subsets.size() < 2",
},
}, nil, nil),
attributes: newValidAttribute(nil, false),
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
{
name: "valid syntax for metadata",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "object.metadata.name == 'endpoints1'",
},
}, nil, nil),
attributes: newValidAttribute(nil, false),
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
{
name: "valid syntax for oldObject",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject == null",
},
{
Expression: "object != null",
},
}, nil, nil),
attributes: newValidAttribute(nil, false),
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
generatedDecision(admit, "", ""),
},
},
{
name: "valid syntax for request",
policy: getValidPolicy([]v1alpha1.Validation{
{Expression: "request.operation == 'CREATE'"},
}, nil, nil),
attributes: newValidAttribute(nil, false),
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
{
name: "valid syntax for configMap",
policy: getValidPolicy([]v1alpha1.Validation{
{Expression: "request.namespace != params.data.fakeString"},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, false),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
{
name: "test failure policy with Ignore",
policy: getValidPolicy([]v1alpha1.Validation{
{Expression: "object.subsets.size() > 2"},
}, hasParamKind, ignorePolicy),
attributes: newValidAttribute(nil, false),
params: &corev1.ConfigMap{
ObjectMeta: metav1.ObjectMeta{
Name: "foo",
},
Data: map[string]string{
"fakeString": "fake",
},
},
policyDecisions: []policyDecision{
generatedDecision(deny, "failed expression: object.subsets.size() > 2", metav1.StatusReasonInvalid),
},
},
{
name: "test failure policy with multiple validations",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "has(object.subsets)",
},
{
Expression: "object.subsets.size() > 2",
},
}, hasParamKind, ignorePolicy),
attributes: newValidAttribute(nil, false),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
generatedDecision(deny, "failed expression: object.subsets.size() > 2", metav1.StatusReasonInvalid),
},
},
{
name: "test failure policy with multiple failed validations",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject != null",
},
{
Expression: "object.subsets.size() > 2",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, false),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(deny, "failed expression: oldObject != null", metav1.StatusReasonInvalid),
generatedDecision(deny, "failed expression: object.subsets.size() > 2", metav1.StatusReasonInvalid),
},
},
{
name: "test Object nul in delete",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject != null",
},
{
Expression: "object == null",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, true),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
generatedDecision(admit, "", ""),
},
},
{
name: "test reason for failed validation",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject == null",
Reason: forbiddenReason,
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, true),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(deny, "failed expression: oldObject == null", metav1.StatusReasonForbidden),
},
},
{
name: "test message for failed validation",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject == null",
Reason: forbiddenReason,
Message: "old object should be present",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, true),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(deny, "old object should be present", metav1.StatusReasonForbidden),
},
},
{
name: "test runtime error",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "oldObject.x == 100",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, true),
params: configMapParams,
policyDecisions: []policyDecision{
generatedDecision(deny, "resulted in error", ""),
},
},
{
name: "test against crd param",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "object.subsets.size() < params.spec.testSize",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, false),
params: crdParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
{
name: "test compile failure with FailurePolicy Fail",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "fail to compile test",
},
{
Expression: "object.subsets.size() > params.spec.testSize",
},
}, hasParamKind, nil),
attributes: newValidAttribute(nil, false),
params: crdParams,
policyDecisions: []policyDecision{
generatedDecision(deny, "compilation error: compilation failed: ERROR: <input>:1:6: Syntax error:", ""),
generatedDecision(deny, "failed expression: object.subsets.size() > params.spec.testSize", metav1.StatusReasonInvalid),
},
},
{
name: "test compile failure with FailurePolicy Ignore",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "fail to compile test",
},
{
Expression: "object.subsets.size() > params.spec.testSize",
},
}, hasParamKind, ignorePolicy),
attributes: newValidAttribute(nil, false),
params: crdParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "compilation error: compilation failed: ERROR:", ""),
generatedDecision(deny, "failed expression: object.subsets.size() > params.spec.testSize", metav1.StatusReasonInvalid),
},
},
{
name: "test pod",
policy: getValidPolicy([]v1alpha1.Validation{
{
Expression: "object.spec.nodeName == 'testnode'",
},
}, nil, nil),
attributes: newValidAttribute(&podObject, false),
params: crdParams,
policyDecisions: []policyDecision{
generatedDecision(admit, "", ""),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
c := CELValidatorCompiler{}
validator := c.Compile(tc.policy)
if validator == nil {
t.Fatalf("unexpected nil validator")
}
validations := tc.policy.Spec.Validations
CompilationResults := validator.(*CELValidator).compilationResults
require.Equal(t, len(validations), len(CompilationResults))
policyResults, err := validator.Validate(tc.attributes, newObjectInterfacesForTest(), tc.params, tc.attributes.GetKind())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.Equal(t, len(policyResults), len(tc.policyDecisions))
for i, policyDecision := range tc.policyDecisions {
if policyDecision.kind != policyResults[i].kind {
t.Errorf("Expected policy decision kind '%v' but got '%v'", policyDecision.kind, policyResults[i].kind)
}
if !strings.Contains(policyResults[i].message, policyDecision.message) {
t.Errorf("Expected policy decision message contains '%v' but got '%v'", policyDecision.message, policyResults[i].message)
}
if policyDecision.reason != policyResults[i].reason {
t.Errorf("Expected policy decision reason '%v' but got '%v'", policyDecision.reason, policyResults[i].reason)
}
}
})
}
}
// newObjectInterfacesForTest returns an ObjectInterfaces appropriate for test cases in this file.
func newObjectInterfacesForTest() admission.ObjectInterfaces {
scheme := runtime.NewScheme()
corev1.AddToScheme(scheme)
return admission.NewObjectInterfacesFromScheme(scheme)
}
func newValidAttribute(object runtime.Object, isDelete bool) admission.Attributes {
var oldObject runtime.Object
if !isDelete {
if object == nil {
object = &corev1.Endpoints{
ObjectMeta: metav1.ObjectMeta{
Name: "endpoints1",
},
Subsets: []corev1.EndpointSubset{
{
Addresses: []corev1.EndpointAddress{{IP: "127.0.0.0"}},
},
},
}
}
} else {
object = nil
oldObject = &corev1.Endpoints{
Subsets: []corev1.EndpointSubset{
{
Addresses: []corev1.EndpointAddress{{IP: "127.0.0.0"}},
},
},
}
}
return admission.NewAttributesRecord(object, oldObject, schema.GroupVersionKind{}, "default", "foo", schema.GroupVersionResource{}, "", admission.Create, &metav1.CreateOptions{}, false, nil)
}