apiserver/pkg/registry/rest/validate_test.go

693 lines
22 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 rest
import (
"bytes"
"context"
"fmt"
"reflect"
"regexp"
"strings"
"testing"
v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/operation"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
"k8s.io/klog/v2"
)
func TestValidateDeclaratively(t *testing.T) {
valid := &Pod{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Pod",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
}
invalidRestartPolicy := &Pod{
TypeMeta: metav1.TypeMeta{
APIVersion: "v1",
Kind: "Pod",
},
ObjectMeta: metav1.ObjectMeta{
Name: "test",
},
RestartPolicy: "INVALID",
}
invalidRestartPolicyErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Invalid value").WithOrigin("invalid-test")
mutatedRestartPolicyErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Immutable field").WithOrigin("immutable-test")
invalidStatusErr := field.Invalid(field.NewPath("status", "conditions"), "", "Invalid condition").WithOrigin("invalid-condition")
invalidIfOptionErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Invalid when option is set").WithOrigin("invalid-when-option-set")
invalidSubresourceErr := field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", fmt.Errorf("invalid subresource path: %s", "invalid/status")))
testCases := []struct {
name string
object runtime.Object
oldObject runtime.Object
subresource string
options sets.Set[string]
expected field.ErrorList
}{
{
name: "create",
object: invalidRestartPolicy,
expected: field.ErrorList{invalidRestartPolicyErr},
},
{
name: "update",
object: invalidRestartPolicy,
oldObject: valid,
expected: field.ErrorList{invalidRestartPolicyErr, mutatedRestartPolicyErr},
},
{
name: "update subresource with declarative validation",
subresource: "status",
object: valid,
oldObject: valid,
expected: field.ErrorList{invalidStatusErr},
},
{
name: "update subresource without declarative validation",
subresource: "scale",
object: valid,
oldObject: valid,
expected: field.ErrorList{}, // Expect no errors if there is no registered validation
},
{
name: "invalid subresource",
subresource: "/invalid/status",
object: valid,
oldObject: valid,
expected: field.ErrorList{invalidSubresourceErr},
},
{
name: "update with option",
options: sets.New("option1"),
object: valid,
expected: field.ErrorList{invalidIfOptionErr},
},
}
ctx := context.Background()
internalGV := schema.GroupVersion{Group: "", Version: runtime.APIVersionInternal}
v1GV := schema.GroupVersion{Group: "", Version: "v1"}
scheme := runtime.NewScheme()
scheme.AddKnownTypes(internalGV, &Pod{})
scheme.AddKnownTypes(v1GV, &v1.Pod{})
scheme.AddValidationFunc(&v1.Pod{}, func(ctx context.Context, op operation.Operation, object, oldObject interface{}) field.ErrorList {
results := field.ErrorList{}
if op.Options.Has("option1") {
results = append(results, invalidIfOptionErr)
}
if len(op.Request.Subresources) == 1 && op.Request.Subresources[0] == "status" {
results = append(results, invalidStatusErr)
}
if op.Type == operation.Update && object.(*v1.Pod).Spec.RestartPolicy != oldObject.(*v1.Pod).Spec.RestartPolicy {
results = append(results, mutatedRestartPolicyErr)
}
if object.(*v1.Pod).Spec.RestartPolicy == "INVALID" {
results = append(results, invalidRestartPolicyErr)
}
return results
})
err := scheme.AddConversionFunc(&Pod{}, &v1.Pod{}, func(a, b interface{}, scope conversion.Scope) error {
if in, ok := a.(*Pod); ok {
if out, ok := b.(*v1.Pod); ok {
out.APIVersion = in.APIVersion
out.Kind = in.Kind
out.Spec.RestartPolicy = v1.RestartPolicy(in.RestartPolicy)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
for _, tc := range testCases {
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
APIGroup: "",
APIVersion: "v1",
Subresource: tc.subresource,
})
t.Run(tc.name, func(t *testing.T) {
var results field.ErrorList
if tc.oldObject == nil {
results = ValidateDeclaratively(ctx, tc.options, scheme, tc.object)
} else {
results = ValidateUpdateDeclaratively(ctx, tc.options, scheme, tc.object, tc.oldObject)
}
matcher := field.ErrorMatcher{}.ByType().ByField().ByOrigin()
matcher.Test(t, tc.expected, results)
})
}
}
// Fake internal pod type, since core.Pod cannot be imported by this package
type Pod struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
RestartPolicy string `json:"restartPolicy"`
}
func (Pod) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind }
func (p Pod) DeepCopyObject() runtime.Object {
return &Pod{
TypeMeta: metav1.TypeMeta{
APIVersion: p.APIVersion,
Kind: p.Kind,
},
ObjectMeta: metav1.ObjectMeta{
Name: p.Name,
Namespace: p.Namespace,
},
RestartPolicy: p.RestartPolicy,
}
}
// TestGatherDeclarativeValidationMismatches tests all mismatch
// scenarios across imperative and declarative errors for
// the gatherDeclarativeValidationMismatches function
func TestGatherDeclarativeValidationMismatches(t *testing.T) {
replicasPath := field.NewPath("spec").Child("replicas")
minReadySecondsPath := field.NewPath("spec").Child("minReadySeconds")
selectorPath := field.NewPath("spec").Child("selector")
errA := field.Invalid(replicasPath, nil, "regular error A")
errB := field.Invalid(minReadySecondsPath, -1, "covered error B").WithOrigin("minimum")
coveredErrB := field.Invalid(minReadySecondsPath, -1, "covered error B").WithOrigin("minimum")
errBWithDiffDetail := field.Invalid(minReadySecondsPath, -1, "covered error B - different detail").WithOrigin("minimum")
coveredErrB.CoveredByDeclarative = true
errC := field.Invalid(replicasPath, nil, "covered error C").WithOrigin("minimum")
coveredErrC := field.Invalid(replicasPath, nil, "covered error C").WithOrigin("minimum")
coveredErrC.CoveredByDeclarative = true
errCWithDiffOrigin := field.Invalid(replicasPath, nil, "covered error C").WithOrigin("maximum")
errD := field.Invalid(selectorPath, nil, "regular error D")
testCases := []struct {
name string
imperativeErrors field.ErrorList
declarativeErrors field.ErrorList
takeover bool
expectMismatches bool
expectDetailsContaining []string
}{
{
name: "Declarative and imperative return 0 errors - no mismatch",
imperativeErrors: field.ErrorList{},
declarativeErrors: field.ErrorList{},
takeover: false,
expectMismatches: false,
expectDetailsContaining: []string{},
},
{
name: "Declarative returns multiple errors with different origins, errors match - no mismatch",
imperativeErrors: field.ErrorList{
errA,
coveredErrB,
coveredErrC,
errD,
},
declarativeErrors: field.ErrorList{
errB,
errC,
},
takeover: false,
expectMismatches: false,
expectDetailsContaining: []string{},
},
{
name: "Declarative returns multiple errors with different origins, errors don't match - mismatch case",
imperativeErrors: field.ErrorList{
errA,
coveredErrB,
coveredErrC,
},
declarativeErrors: field.ErrorList{
errB,
errCWithDiffOrigin,
},
takeover: true,
expectMismatches: true,
expectDetailsContaining: []string{
"Unexpected difference between hand written validation and declarative validation error results",
"unmatched error(s) found",
"extra error(s) found",
"replicas",
"Consider disabling the DeclarativeValidationTakeover feature gate to keep data persisted in etcd consistent with prior versions of Kubernetes",
},
},
{
name: "Declarative and imperative return exactly 1 error, errors match - no mismatch",
imperativeErrors: field.ErrorList{
coveredErrB,
},
declarativeErrors: field.ErrorList{
errB,
},
takeover: false,
expectMismatches: false,
expectDetailsContaining: []string{},
},
{
name: "Declarative and imperative exactly 1 error, errors don't match - mismatch",
imperativeErrors: field.ErrorList{
coveredErrB,
},
declarativeErrors: field.ErrorList{
errC,
},
takeover: false,
expectMismatches: true,
expectDetailsContaining: []string{
"Unexpected difference between hand written validation and declarative validation error results",
"unmatched error(s) found",
"minReadySeconds",
"extra error(s) found",
"replicas",
"This difference should not affect system operation since hand written validation is authoritative",
},
},
{
name: "Declarative returns 0 errors, imperative returns 1 covered error - mismatch",
imperativeErrors: field.ErrorList{
coveredErrB,
},
declarativeErrors: field.ErrorList{},
takeover: true,
expectMismatches: true,
expectDetailsContaining: []string{
"Unexpected difference between hand written validation and declarative validation error results",
"unmatched error(s) found",
"minReadySeconds",
"Consider disabling the DeclarativeValidationTakeover feature gate to keep data persisted in etcd consistent with prior versions of Kubernetes",
},
},
{
name: "Declarative returns 0 errors, imperative returns 1 uncovered error - no mismatch",
imperativeErrors: field.ErrorList{
errB,
},
declarativeErrors: field.ErrorList{},
takeover: false,
expectMismatches: false,
expectDetailsContaining: []string{},
},
{
name: "Declarative returns 1 error, imperative returns 0 error - mismatch",
imperativeErrors: field.ErrorList{},
declarativeErrors: field.ErrorList{
errB,
},
takeover: false,
expectMismatches: true,
expectDetailsContaining: []string{
"Unexpected difference between hand written validation and declarative validation error results",
"extra error(s) found",
"minReadySeconds",
"This difference should not affect system operation since hand written validation is authoritative",
},
},
{
name: "Declarative returns 1 error, imperative returns 3 matching errors - no mismatch",
imperativeErrors: field.ErrorList{
coveredErrB,
},
declarativeErrors: field.ErrorList{
errB,
errB,
errBWithDiffDetail,
},
takeover: false,
expectMismatches: false,
expectDetailsContaining: []string{},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
details := gatherDeclarativeValidationMismatches(tc.imperativeErrors, tc.declarativeErrors, tc.takeover)
// Check if mismatches were found if expected
if tc.expectMismatches && len(details) == 0 {
t.Errorf("Expected mismatches but got none")
}
// Check if details contain expected text
detailsStr := strings.Join(details, " ")
for _, expectedContent := range tc.expectDetailsContaining {
if !strings.Contains(detailsStr, expectedContent) {
t.Errorf("Expected details to contain: %q, but they didn't.\nDetails were:\n%s",
expectedContent, strings.Join(details, "\n"))
}
}
// If we don't expect any details, make sure none provided
if len(tc.expectDetailsContaining) == 0 && len(details) > 0 {
t.Errorf("Expected no details, but got %d details: %v", len(details), details)
}
})
}
}
// TestCompareDeclarativeErrorsAndEmitMismatches tests expected
// logging of mismatch information given match & mismatch error conditions.
func TestCompareDeclarativeErrorsAndEmitMismatches(t *testing.T) {
replicasPath := field.NewPath("spec").Child("replicas")
minReadySecondsPath := field.NewPath("spec").Child("minReadySeconds")
errA := field.Invalid(replicasPath, nil, "regular error A")
errB := field.Invalid(minReadySecondsPath, -1, "covered error B").WithOrigin("minimum")
coveredErrB := field.Invalid(minReadySecondsPath, -1, "covered error B").WithOrigin("minimum")
coveredErrB.CoveredByDeclarative = true
testCases := []struct {
name string
imperativeErrs field.ErrorList
declarativeErrs field.ErrorList
takeover bool
expectLogs bool
expectedRegex string
}{
{
name: "mismatched errors, log info",
imperativeErrs: field.ErrorList{coveredErrB},
declarativeErrs: field.ErrorList{errA},
takeover: true,
expectLogs: true,
// logs have a prefix of the form - E0309 21:05:33.865030 1926106 validate.go:199]
expectedRegex: "E.*Unexpected difference between hand written validation and declarative validation error results.*Consider disabling the DeclarativeValidationTakeover feature gate to keep data persisted in etcd consistent with prior versions of Kubernetes",
},
{
name: "matching errors, don't log info",
imperativeErrs: field.ErrorList{coveredErrB},
declarativeErrs: field.ErrorList{errB},
takeover: true,
expectLogs: false,
expectedRegex: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
klog.SetOutput(&buf)
klog.LogToStderr(false)
defer klog.LogToStderr(true)
ctx := context.Background()
CompareDeclarativeErrorsAndEmitMismatches(ctx, tc.imperativeErrs, tc.declarativeErrs, tc.takeover)
klog.Flush()
logOutput := buf.String()
if tc.expectLogs {
matched, err := regexp.MatchString(tc.expectedRegex, logOutput)
if err != nil {
t.Fatalf("Bad regex: %v", err)
}
if !matched {
t.Errorf("Expected log output to match %q, but got:\n%s", tc.expectedRegex, logOutput)
}
} else if len(logOutput) > 0 {
t.Errorf("Expected no mismatch logs, but found: %s", logOutput)
}
})
}
}
func TestWithRecover(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
options := sets.New[string]()
obj := &runtime.Unknown{}
testCases := []struct {
name string
validateFn func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object) field.ErrorList
takeoverEnabled bool
wantErrs field.ErrorList
expectLogRegex string
}{
{
name: "no panic",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object) field.ErrorList {
return field.ErrorList{
field.Invalid(field.NewPath("field"), "value", "reason"),
}
},
takeoverEnabled: false,
wantErrs: field.ErrorList{
field.Invalid(field.NewPath("field"), "value", "reason"),
},
expectLogRegex: "",
},
{
name: "panic with takeover disabled",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object) field.ErrorList {
panic("test panic")
},
takeoverEnabled: false,
wantErrs: nil,
// logs have a prefix of the form - E0309 21:05:33.865030 1926106 validate.go:199]
expectLogRegex: "E.*panic during declarative validation: test panic",
},
{
name: "panic with takeover enabled",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object) field.ErrorList {
panic("test panic")
},
takeoverEnabled: true,
wantErrs: field.ErrorList{
field.InternalError(nil, fmt.Errorf("panic during declarative validation: test panic")),
},
expectLogRegex: "",
},
{
name: "nil return, no panic",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object) field.ErrorList {
return nil
},
takeoverEnabled: false,
wantErrs: nil,
expectLogRegex: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
klog.SetOutput(&buf)
klog.LogToStderr(false)
defer klog.LogToStderr(true)
// Pass the takeover flag to panicSafeValidateFunc instead of relying on the feature gate
wrapped := panicSafeValidateFunc(tc.validateFn, tc.takeoverEnabled)
gotErrs := wrapped(ctx, options, scheme, obj)
klog.Flush()
logOutput := buf.String()
// Compare gotErrs vs. tc.wantErrs
if !equalErrorLists(gotErrs, tc.wantErrs) {
t.Errorf("panicSafeValidateFunc() gotErrs = %#v, want %#v", gotErrs, tc.wantErrs)
}
// Check logs if needed
if tc.expectLogRegex != "" {
matched, err := regexp.MatchString(tc.expectLogRegex, logOutput)
if err != nil {
t.Fatalf("Bad regex: %v", err)
}
if !matched {
t.Errorf("Expected log output %q, but got:\n%s", tc.expectLogRegex, logOutput)
}
} else if strings.Contains(logOutput, "panic during declarative validation") {
t.Errorf("Unexpected panic log found: %s", logOutput)
}
})
}
}
func TestWithRecoverUpdate(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
options := sets.New[string]()
obj := &runtime.Unknown{}
oldObj := &runtime.Unknown{}
testCases := []struct {
name string
validateFn func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object, runtime.Object) field.ErrorList
takeoverEnabled bool
wantErrs field.ErrorList
expectLogRegex string
}{
{
name: "no panic",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object, runtime.Object) field.ErrorList {
return field.ErrorList{
field.Invalid(field.NewPath("field"), "value", "reason"),
}
},
takeoverEnabled: false,
wantErrs: field.ErrorList{
field.Invalid(field.NewPath("field"), "value", "reason"),
},
expectLogRegex: "",
},
{
name: "panic with takeover disabled",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object, runtime.Object) field.ErrorList {
panic("test update panic")
},
takeoverEnabled: false,
wantErrs: nil,
// logs have a prefix of the form - E0309 21:05:33.865030 1926106 validate.go:199]
expectLogRegex: "E.*panic during declarative validation: test update panic",
},
{
name: "panic with takeover enabled",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object, runtime.Object) field.ErrorList {
panic("test update panic")
},
takeoverEnabled: true,
wantErrs: field.ErrorList{
field.InternalError(nil, fmt.Errorf("panic during declarative validation: test update panic")),
},
expectLogRegex: "",
},
{
name: "nil return, no panic",
validateFn: func(context.Context, sets.Set[string], *runtime.Scheme, runtime.Object, runtime.Object) field.ErrorList {
return nil
},
takeoverEnabled: false,
wantErrs: nil,
expectLogRegex: "",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
klog.SetOutput(&buf)
klog.LogToStderr(false)
defer klog.LogToStderr(true)
// Pass the takeover flag to panicSafeValidateUpdateFunc instead of relying on the feature gate
wrapped := panicSafeValidateUpdateFunc(tc.validateFn, tc.takeoverEnabled)
gotErrs := wrapped(ctx, options, scheme, obj, oldObj)
klog.Flush()
logOutput := buf.String()
// Compare gotErrs with wantErrs
if !equalErrorLists(gotErrs, tc.wantErrs) {
t.Errorf("panicSafeValidateUpdateFunc() gotErrs = %#v, want %#v", gotErrs, tc.wantErrs)
}
// Verify log output
if tc.expectLogRegex != "" {
matched, err := regexp.MatchString(tc.expectLogRegex, logOutput)
if err != nil {
t.Fatalf("Bad regex: %v", err)
}
if !matched {
t.Errorf("Expected log pattern %q, but got:\n%s", tc.expectLogRegex, logOutput)
}
} else if strings.Contains(logOutput, "panic during declarative validation") {
t.Errorf("Unexpected panic log found: %s", logOutput)
}
})
}
}
func TestValidateDeclarativelyWithRecovery(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
options := sets.New[string]()
obj := &runtime.Unknown{}
// Simple test for the ValidateDeclarativelyWithRecovery function
t.Run("with takeover disabled", func(t *testing.T) {
errs := ValidateDeclarativelyWithRecovery(ctx, options, scheme, obj, false)
if errs == nil {
// This is expected to error since the request info is missing
t.Errorf("Expected errors but got nil")
}
})
t.Run("with takeover enabled", func(t *testing.T) {
errs := ValidateDeclarativelyWithRecovery(ctx, options, scheme, obj, true)
if errs == nil {
// This is expected to error since the request info is missing
t.Errorf("Expected errors but got nil")
}
})
}
func TestValidateUpdateDeclarativelyWithRecovery(t *testing.T) {
ctx := context.Background()
scheme := runtime.NewScheme()
options := sets.New[string]()
obj := &runtime.Unknown{}
oldObj := &runtime.Unknown{}
// Simple test for the ValidateUpdateDeclarativelyWithRecovery function
t.Run("with takeover disabled", func(t *testing.T) {
errs := ValidateUpdateDeclarativelyWithRecovery(ctx, options, scheme, obj, oldObj, false)
if errs == nil {
// This is expected to error since the request info is missing
t.Errorf("Expected errors but got nil")
}
})
t.Run("with takeover enabled", func(t *testing.T) {
errs := ValidateUpdateDeclarativelyWithRecovery(ctx, options, scheme, obj, oldObj, true)
if errs == nil {
// This is expected to error since the request info is missing
t.Errorf("Expected errors but got nil")
}
})
}
func equalErrorLists(a, b field.ErrorList) bool {
// If both are nil, consider them equal
if a == nil && b == nil {
return true
}
// If one is nil and the other not, they're different
if (a == nil && b != nil) || (a != nil && b == nil) {
return false
}
// Both non-nil: do a normal DeepEqual
return reflect.DeepEqual(a, b)
}