693 lines
22 KiB
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)
|
|
}
|