305 lines
11 KiB
Go
305 lines
11 KiB
Go
/*
|
|
Copyright 2025 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 testing
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"sort"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
|
runtimetest "k8s.io/apimachinery/pkg/runtime/testing"
|
|
"k8s.io/apimachinery/pkg/util/validation/field"
|
|
"k8s.io/apiserver/pkg/features"
|
|
utilfeature "k8s.io/apiserver/pkg/util/feature"
|
|
featuregatetesting "k8s.io/component-base/featuregate/testing"
|
|
"k8s.io/kubernetes/pkg/api/legacyscheme"
|
|
)
|
|
|
|
// ValidateFunc is a function that runs validation.
|
|
type ValidateFunc func(ctx context.Context, obj runtime.Object) field.ErrorList
|
|
|
|
// ValidateUpdateFunc is a function that runs update validation.
|
|
type ValidateUpdateFunc func(ctx context.Context, obj, old runtime.Object) field.ErrorList
|
|
|
|
// VerifyVersionedValidationEquivalence tests that all versions of an API return equivalent validation errors.
|
|
func VerifyVersionedValidationEquivalence(t *testing.T, obj, old runtime.Object, subResources ...string) {
|
|
t.Helper()
|
|
|
|
// Accumulate errors from all versioned validation, per version.
|
|
all := map[string]field.ErrorList{}
|
|
accumulate := func(t *testing.T, gv string, errs field.ErrorList) {
|
|
all[gv] = errs
|
|
}
|
|
// Convert versioned object to internal format before validation.
|
|
// runtimetest.RunValidationForEachVersion requires unversioned (internal) objects as input.
|
|
internalObj, err := convertToInternal(t, legacyscheme.Scheme, obj)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if internalObj == nil {
|
|
return
|
|
}
|
|
if old == nil {
|
|
runtimetest.RunValidationForEachVersion(t, legacyscheme.Scheme, []string{}, internalObj, accumulate, subResources...)
|
|
} else {
|
|
// Convert old versioned object to internal format before validation.
|
|
// runtimetest.RunUpdateValidationForEachVersion requires unversioned (internal) objects as input.
|
|
internalOld, err := convertToInternal(t, legacyscheme.Scheme, old)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if internalOld == nil {
|
|
return
|
|
}
|
|
runtimetest.RunUpdateValidationForEachVersion(t, legacyscheme.Scheme, []string{}, internalObj, internalOld, accumulate, subResources...)
|
|
}
|
|
|
|
// Make a copy so we can modify it.
|
|
other := map[string]field.ErrorList{}
|
|
// Index for nicer output.
|
|
keys := []string{}
|
|
for k, v := range all {
|
|
other[k] = v
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
// Compare each lhs to each rhs.
|
|
for _, lk := range keys {
|
|
lv := all[lk]
|
|
// remove lk since to prevent comparison to itself and because this
|
|
// iteration will compare it to any version it has not yet been
|
|
// compared to. e.g. [1, 2, 3] vs. [1, 2, 3] yields:
|
|
// 1 vs. 2
|
|
// 1 vs. 3
|
|
// 2 vs. 3
|
|
delete(other, lk)
|
|
// don't compare to ourself
|
|
for _, rk := range keys {
|
|
rv, found := other[rk]
|
|
if !found {
|
|
continue // done already
|
|
}
|
|
if len(lv) != len(rv) {
|
|
t.Errorf("different error count (%d vs. %d)\n%s: %v\n%s: %v", len(lv), len(rv), lk, fmtErrs(lv), rk, fmtErrs(rv))
|
|
continue
|
|
}
|
|
next := false
|
|
for i := range lv {
|
|
if l, r := lv[i], rv[i]; l.Type != r.Type || l.Detail != r.Detail {
|
|
t.Errorf("different errors\n%s: %v\n%s: %v", lk, fmtErrs(lv), rk, fmtErrs(rv))
|
|
next = true
|
|
break
|
|
}
|
|
}
|
|
if next {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// helper for nicer output
|
|
func fmtErrs(errs field.ErrorList) string {
|
|
if len(errs) == 0 {
|
|
return "<no errors>"
|
|
}
|
|
if len(errs) == 1 {
|
|
return strconv.Quote(errs[0].Error())
|
|
}
|
|
buf := bytes.Buffer{}
|
|
for _, e := range errs {
|
|
buf.WriteString("\n")
|
|
buf.WriteString(strconv.Quote(e.Error()))
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func convertToInternal(t *testing.T, scheme *runtime.Scheme, obj runtime.Object) (runtime.Object, error) {
|
|
t.Helper()
|
|
|
|
gvks, _, err := scheme.ObjectKinds(obj)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(gvks) == 0 {
|
|
t.Fatal("no GVKs found for object")
|
|
}
|
|
gvk := gvks[0]
|
|
if gvk.Version == runtime.APIVersionInternal {
|
|
return obj, nil
|
|
}
|
|
gvk.Version = runtime.APIVersionInternal
|
|
if !scheme.Recognizes(gvk) {
|
|
t.Logf("no internal object found for GroupKind %s", gvk.GroupKind().String())
|
|
return nil, nil
|
|
}
|
|
return scheme.ConvertToVersion(obj, schema.GroupVersion{Group: gvk.Group, Version: runtime.APIVersionInternal})
|
|
}
|
|
|
|
type ValidationTestConfig func(*validationOption)
|
|
|
|
// validationOptions encapsulates optional parameters for validation equivalence tests.
|
|
type validationOption struct {
|
|
// SubResources are the subresources to validate.
|
|
SubResources []string
|
|
// NormalizationRules are the rules to apply to field paths before comparison.
|
|
NormalizationRules []field.NormalizationRule
|
|
}
|
|
|
|
func WithSubResources(subResources ...string) ValidationTestConfig {
|
|
return func(o *validationOption) {
|
|
o.SubResources = subResources
|
|
}
|
|
}
|
|
|
|
func WithNormalizationRules(rules ...field.NormalizationRule) ValidationTestConfig {
|
|
return func(o *validationOption) {
|
|
o.NormalizationRules = rules
|
|
}
|
|
}
|
|
|
|
// VerifyValidationEquivalence provides a helper for testing the migration from
|
|
// hand-written imperative validation to declarative validation. It ensures that
|
|
// the validation logic remains consistent before and after the feature is enabled.
|
|
//
|
|
// The function operates by running the provided validation function under two scenarios:
|
|
// 1. With DeclarativeValidation and DeclarativeValidationTakeover feature gates disabled,
|
|
// simulating the legacy hand-written validation.
|
|
// 2. With both feature gates enabled, using the new declarative validation rules.
|
|
//
|
|
// It then asserts that the validation errors produced in both scenarios are equivalent,
|
|
// guaranteeing a safe migration. It also checks the errors against an expected set.
|
|
// It compares errors by field, origin and type; all three should match to be called equivalent.
|
|
// It also make sure all versions of the given API returns equivalent errors.
|
|
func VerifyValidationEquivalence(t *testing.T, ctx context.Context, obj runtime.Object, validateFn ValidateFunc, expectedErrs field.ErrorList, testConfigs ...ValidationTestConfig) {
|
|
t.Helper()
|
|
opts := &validationOption{}
|
|
for _, testcfg := range testConfigs {
|
|
testcfg(opts)
|
|
}
|
|
verifyValidationEquivalence(t, expectedErrs, func() field.ErrorList {
|
|
return validateFn(ctx, obj)
|
|
}, opts)
|
|
VerifyVersionedValidationEquivalence(t, obj, nil, opts.SubResources...)
|
|
}
|
|
|
|
// VerifyUpdateValidationEquivalence provides a helper for testing the migration from
|
|
// hand-written imperative validation to declarative validation for update operations.
|
|
// It ensures that the validation logic remains consistent before and after the feature is enabled.
|
|
//
|
|
// The function operates by running the provided validation function under two scenarios:
|
|
// 1. With DeclarativeValidation and DeclarativeValidationTakeover feature gates disabled,
|
|
// simulating the legacy hand-written validation.
|
|
// 2. With both feature gates enabled, using the new declarative validation rules.
|
|
//
|
|
// It then asserts that the validation errors produced in both scenarios are equivalent,
|
|
// guaranteeing a safe migration. It also checks the errors against an expected set.
|
|
// It compares errors by field, origin and type; all three should match to be called equivalent.
|
|
// It also make sure all versions of the given API returns equivalent errors.
|
|
func VerifyUpdateValidationEquivalence(t *testing.T, ctx context.Context, obj, old runtime.Object, validateUpdateFn ValidateUpdateFunc, expectedErrs field.ErrorList, testConfigs ...ValidationTestConfig) {
|
|
t.Helper()
|
|
opts := &validationOption{}
|
|
for _, testcfg := range testConfigs {
|
|
testcfg(opts)
|
|
}
|
|
verifyValidationEquivalence(t, expectedErrs, func() field.ErrorList {
|
|
return validateUpdateFn(ctx, obj, old)
|
|
}, opts)
|
|
VerifyVersionedValidationEquivalence(t, obj, old, opts.SubResources...)
|
|
}
|
|
|
|
// verifyValidationEquivalence is a generic helper that verifies validation equivalence with and without declarative validation.
|
|
func verifyValidationEquivalence(t *testing.T, expectedErrs field.ErrorList, runValidations func() field.ErrorList, opt *validationOption) {
|
|
t.Helper()
|
|
var declarativeTakeoverErrs field.ErrorList
|
|
var imperativeErrs field.ErrorList
|
|
|
|
// The errOutputMatcher is used to verify the output matches the expected errors in test cases.
|
|
errOutputMatcher := field.ErrorMatcher{}.ByType().ByOrigin().ByFieldNormalized(opt.NormalizationRules)
|
|
|
|
// We only need to test both gate enabled and disabled together, because
|
|
// 1) the DeclarativeValidationTakeover won't take effect if DeclarativeValidation is disabled.
|
|
// 2) the validation output, when only DeclarativeValidation is enabled, is the same as when both gates are disabled.
|
|
t.Run("with declarative validation", func(t *testing.T) {
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DeclarativeValidation, true)
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DeclarativeValidationTakeover, true)
|
|
declarativeTakeoverErrs = runValidations()
|
|
|
|
if len(expectedErrs) > 0 {
|
|
errOutputMatcher.Test(t, expectedErrs, declarativeTakeoverErrs)
|
|
} else if len(declarativeTakeoverErrs) != 0 {
|
|
t.Errorf("expected no errors, but got: %v", declarativeTakeoverErrs)
|
|
}
|
|
})
|
|
|
|
t.Run("hand written validation", func(t *testing.T) {
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DeclarativeValidationTakeover, false)
|
|
featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.DeclarativeValidation, false)
|
|
imperativeErrs = runValidations()
|
|
|
|
if len(expectedErrs) > 0 {
|
|
errOutputMatcher.Test(t, expectedErrs, imperativeErrs)
|
|
} else if len(imperativeErrs) != 0 {
|
|
t.Errorf("expected no errors, but got: %v", imperativeErrs)
|
|
}
|
|
})
|
|
|
|
if t.Failed() {
|
|
// There is no point in moving forward, if any of above tests failed for any reason. Running follow up tests will return noise.
|
|
t.SkipNow()
|
|
}
|
|
|
|
// The equivalenceMatcher is used to verify the output errors from hand-written imperative validation
|
|
// are equivalent to the output errors when DeclarativeValidationTakeover is enabled.
|
|
equivalenceMatcher := field.ErrorMatcher{}.ByType().ByOrigin()
|
|
if len(opt.NormalizationRules) > 0 {
|
|
equivalenceMatcher = equivalenceMatcher.ByFieldNormalized(opt.NormalizationRules)
|
|
} else {
|
|
equivalenceMatcher = equivalenceMatcher.ByField()
|
|
}
|
|
|
|
// The imperative validation may produce duplicate errors, which is not supported by the ErrorMatcher.
|
|
// TODO: remove this once ErrorMatcher has been extended to handle this form of deduplication.
|
|
imperativeErrs = deDuplicateErrors(imperativeErrs, equivalenceMatcher)
|
|
|
|
equivalenceMatcher.Test(t, imperativeErrs, declarativeTakeoverErrs)
|
|
}
|
|
|
|
// deDuplicateErrors removes duplicate errors from an ErrorList based on the provided matcher.
|
|
func deDuplicateErrors(errs field.ErrorList, matcher field.ErrorMatcher) field.ErrorList {
|
|
var deduped field.ErrorList
|
|
for _, err := range errs {
|
|
found := false
|
|
for _, existingErr := range deduped {
|
|
if matcher.Matches(existingErr, err) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
deduped = append(deduped, err)
|
|
}
|
|
}
|
|
return deduped
|
|
}
|