Add an error matcher, convert 2 tests

I fixed up the TestValidateEndpointsCreate path to show the matcher
instead of manual origin checking.

I picked TestValidateTopologySpreadConstraints because it was the last
failing test on my screen when I changed on of the commonly hard-coded
error strings. I fixed exactly those validation errors that were needed
to make this test pass.  Some of the Origin values can be debated.

The `field/testing.Matcher` interface allows tests to configure the
criteria by which they want to match expected and actual errors.  The
hope is that everyone will use Origin for Invalid errors.

There's some collateral impact for tests which use exact-comparisons and
don't expect origins.  These are all candidates for using the matcher.
This commit is contained in:
Tim Hockin 2025-02-23 22:53:29 -08:00
parent 6b7e38f018
commit c8111709e5
No known key found for this signature in database
8 changed files with 361 additions and 107 deletions

View File

@ -523,7 +523,7 @@ func TestValidateStatefulSet(t *testing.T) {
},
}
cmpOpts := []cmp.Option{cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail"), cmpopts.SortSlices(func(a, b *field.Error) bool { return a.Error() < b.Error() })}
cmpOpts := []cmp.Option{cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail", "Origin"), cmpopts.SortSlices(func(a, b *field.Error) bool { return a.Error() < b.Error() })}
for _, testCase := range append(successCases, errorCases...) {
name := testCase.name
var testTitle string
@ -936,7 +936,7 @@ func TestValidateStatefulSetUpdate(t *testing.T) {
}
cmpOpts := []cmp.Option{
cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail"),
cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail", "Origin"),
cmpopts.SortSlices(func(a, b *field.Error) bool { return a.Error() < b.Error() }),
cmpopts.EquateEmpty(),
}

View File

@ -48,7 +48,7 @@ var (
timeZoneEmptySpace = " "
)
var ignoreErrValueDetail = cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail")
var ignoreErrValueDetail = cmpopts.IgnoreFields(field.Error{}, "BadValue", "Detail", "Origin")
func getValidManualSelector() *metav1.LabelSelector {
return &metav1.LabelSelector{

View File

@ -350,6 +350,15 @@ func ValidateNonnegativeQuantity(value resource.Quantity, fldPath *field.Path) f
return allErrs
}
// Validates that given value is positive.
func ValidatePositiveField(value int64, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
if value <= 0 {
allErrs = append(allErrs, field.Invalid(fldPath, value, isNotPositiveErrorMsg).WithOrigin("minimum"))
}
return allErrs
}
// Validates that a Quantity is positive
func ValidatePositiveQuantityValue(value resource.Quantity, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
@ -7905,8 +7914,8 @@ func validateTopologySpreadConstraints(constraints []core.TopologySpreadConstrai
for i, constraint := range constraints {
subFldPath := fldPath.Index(i)
if err := ValidateMaxSkew(subFldPath.Child("maxSkew"), constraint.MaxSkew); err != nil {
allErrs = append(allErrs, err)
if errs := ValidatePositiveField(int64(constraint.MaxSkew), subFldPath.Child("maxSkew")); len(errs) > 0 {
allErrs = append(allErrs, errs...)
}
if err := ValidateTopologyKey(subFldPath.Child("topologyKey"), constraint.TopologyKey); err != nil {
allErrs = append(allErrs, err)
@ -7934,26 +7943,16 @@ func validateTopologySpreadConstraints(constraints []core.TopologySpreadConstrai
return allErrs
}
// ValidateMaxSkew tests that the argument is a valid MaxSkew.
func ValidateMaxSkew(fldPath *field.Path, maxSkew int32) *field.Error {
if maxSkew <= 0 {
return field.Invalid(fldPath, maxSkew, isNotPositiveErrorMsg)
}
return nil
}
// validateMinDomains tests that the argument is a valid MinDomains.
func validateMinDomains(fldPath *field.Path, minDomains *int32, action core.UnsatisfiableConstraintAction) field.ErrorList {
if minDomains == nil {
return nil
}
var allErrs field.ErrorList
if *minDomains <= 0 {
allErrs = append(allErrs, field.Invalid(fldPath, minDomains, isNotPositiveErrorMsg))
}
allErrs = append(allErrs, ValidatePositiveField(int64(*minDomains), fldPath)...)
// When MinDomains is non-nil, whenUnsatisfiable must be DoNotSchedule.
if action != core.DoNotSchedule {
allErrs = append(allErrs, field.Invalid(fldPath, minDomains, fmt.Sprintf("can only use minDomains if whenUnsatisfiable=%s, not %s", core.DoNotSchedule, action)))
allErrs = append(allErrs, field.Invalid(fldPath, minDomains, fmt.Sprintf("can only use minDomains if whenUnsatisfiable=%s, not %s", core.DoNotSchedule, action)).WithOrigin("dependsOn"))
}
return allErrs
}
@ -8077,7 +8076,7 @@ func validateMatchLabelKeysInTopologySpread(fldPath *field.Path, matchLabelKeys
for i, key := range matchLabelKeys {
allErrs = append(allErrs, unversionedvalidation.ValidateLabelName(key, fldPath.Index(i))...)
if labelSelectorKeys.Has(key) {
allErrs = append(allErrs, field.Invalid(fldPath.Index(i), key, "exists in both matchLabelKeys and labelSelector"))
allErrs = append(allErrs, field.Invalid(fldPath.Index(i), key, "exists in both matchLabelKeys and labelSelector").WithOrigin("overlappingKeys"))
}
}

View File

@ -38,6 +38,7 @@ import (
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
fldtest "k8s.io/apimachinery/pkg/util/validation/field/testing"
"k8s.io/apimachinery/pkg/util/version"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"k8s.io/component-base/featuregate"
@ -20718,24 +20719,31 @@ func TestValidateEndpointsCreate(t *testing.T) {
errorCases := map[string]struct {
endpoints core.Endpoints
errorType field.ErrorType
errorOrigin string
expectedErrs field.ErrorList
}{
"missing namespace": {
endpoints: core.Endpoints{ObjectMeta: metav1.ObjectMeta{Name: "mysvc"}},
errorType: "FieldValueRequired",
expectedErrs: field.ErrorList{
field.Required(field.NewPath("metadata.namespace"), ""),
},
},
"missing name": {
endpoints: core.Endpoints{ObjectMeta: metav1.ObjectMeta{Namespace: "namespace"}},
errorType: "FieldValueRequired",
expectedErrs: field.ErrorList{
field.Required(field.NewPath("metadata.name"), ""),
},
},
"invalid namespace": {
endpoints: core.Endpoints{ObjectMeta: metav1.ObjectMeta{Name: "mysvc", Namespace: "no@#invalid.;chars\"allowed"}},
errorType: "FieldValueInvalid",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("metadata.namespace"), nil, ""),
},
},
"invalid name": {
endpoints: core.Endpoints{ObjectMeta: metav1.ObjectMeta{Name: "-_Invliad^&Characters", Namespace: "namespace"}},
errorType: "FieldValueInvalid",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("metadata.name"), nil, ""),
},
},
"empty addresses": {
endpoints: core.Endpoints{
@ -20744,7 +20752,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueRequired",
expectedErrs: field.ErrorList{
field.Required(field.NewPath("subsets[0]"), ""),
},
},
"invalid IP": {
endpoints: core.Endpoints{
@ -20754,8 +20764,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=ip-sloppy",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].addresses[0].ip"), nil, "").WithOrigin("format=ip-sloppy"),
},
},
"Multiple ports, one without name": {
endpoints: core.Endpoints{
@ -20765,7 +20776,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Port: 8675, Protocol: "TCP"}, {Name: "b", Port: 309, Protocol: "TCP"}},
}},
},
errorType: "FieldValueRequired",
expectedErrs: field.ErrorList{
field.Required(field.NewPath("subsets[0].ports[0].name"), ""),
},
},
"Invalid port number": {
endpoints: core.Endpoints{
@ -20775,8 +20788,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 66000, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "portNum",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].ports[0].port"), nil, "").WithOrigin("portNum"),
},
},
"Invalid protocol": {
endpoints: core.Endpoints{
@ -20786,7 +20800,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 93, Protocol: "Protocol"}},
}},
},
errorType: "FieldValueNotSupported",
expectedErrs: field.ErrorList{
field.NotSupported[string](field.NewPath("subsets[0].ports[0].protocol"), nil, nil),
},
},
"Address missing IP": {
endpoints: core.Endpoints{
@ -20796,8 +20812,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=ip-sloppy",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].addresses[0].ip"), nil, "").WithOrigin("format=ip-sloppy"),
},
},
"Port missing number": {
endpoints: core.Endpoints{
@ -20807,8 +20824,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "portNum",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].ports[0].port"), nil, "").WithOrigin("portNum"),
},
},
"Port missing protocol": {
endpoints: core.Endpoints{
@ -20818,7 +20836,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "a", Port: 93}},
}},
},
errorType: "FieldValueRequired",
expectedErrs: field.ErrorList{
field.Required(field.NewPath("subsets[0].ports[0].protocol"), ""),
},
},
"Address is loopback": {
endpoints: core.Endpoints{
@ -20828,8 +20848,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "p", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=non-special-ip",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].addresses[0].ip"), nil, "").WithOrigin("format=non-special-ip"),
},
},
"Address is link-local": {
endpoints: core.Endpoints{
@ -20839,8 +20860,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "p", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=non-special-ip",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].addresses[0].ip"), nil, "").WithOrigin("format=non-special-ip"),
},
},
"Address is link-local multicast": {
endpoints: core.Endpoints{
@ -20850,8 +20872,9 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "p", Port: 93, Protocol: "TCP"}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=non-special-ip",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].addresses[0].ip"), nil, "").WithOrigin("format=non-special-ip"),
},
},
"Invalid AppProtocol": {
endpoints: core.Endpoints{
@ -20861,16 +20884,18 @@ func TestValidateEndpointsCreate(t *testing.T) {
Ports: []core.EndpointPort{{Name: "p", Port: 93, Protocol: "TCP", AppProtocol: utilpointer.String("lots-of[invalid]-{chars}")}},
}},
},
errorType: "FieldValueInvalid",
errorOrigin: "format=qualified-name",
expectedErrs: field.ErrorList{
field.Invalid(field.NewPath("subsets[0].ports[0].appProtocol"), nil, "").WithOrigin("format=qualified-name"),
},
},
}
for k, v := range errorCases {
t.Run(k, func(t *testing.T) {
if errs := ValidateEndpointsCreate(&v.endpoints); len(errs) == 0 || errs[0].Type != v.errorType || errs[0].Origin != v.errorOrigin {
t.Errorf("Expected error type %s with origin %q, got %#v", v.errorType, v.errorOrigin, errs[0])
}
errs := ValidateEndpointsCreate(&v.endpoints)
// TODO: set .RequireOriginWhenInvalid() once metadata is done
matcher := fldtest.Match().ByType().ByField().ByOrigin()
fldtest.MatchErrors(t, v.expectedErrs, errs, matcher)
})
}
}
@ -22630,8 +22655,8 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
testCases := []struct {
name string
constraints []core.TopologySpreadConstraint
wantFieldErrors field.ErrorList
opts PodValidationOptions
wantFieldErrors field.ErrorList
}{{
name: "all required fields ok",
constraints: []core.TopologySpreadConstraint{{
@ -22640,19 +22665,23 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.DoNotSchedule,
MinDomains: utilpointer.Int32(3),
}},
wantFieldErrors: field.ErrorList{},
wantFieldErrors: nil,
}, {
name: "missing MaxSkew",
constraints: []core.TopologySpreadConstraint{
{TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
},
wantFieldErrors: []*field.Error{field.Invalid(fieldPathMaxSkew, int32(0), isNotPositiveErrorMsg)},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMaxSkew, nil, "").WithOrigin("minimum"),
},
}, {
name: "negative MaxSkew",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: -1, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
},
wantFieldErrors: []*field.Error{field.Invalid(fieldPathMaxSkew, int32(-1), isNotPositiveErrorMsg)},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMaxSkew, nil, "").WithOrigin("minimum"),
},
}, {
name: "can use MinDomains with ScheduleAnyway, when MinDomains = nil",
constraints: []core.TopologySpreadConstraint{{
@ -22661,7 +22690,7 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.ScheduleAnyway,
MinDomains: nil,
}},
wantFieldErrors: field.ErrorList{},
wantFieldErrors: nil,
}, {
name: "negative minDomains is invalid",
constraints: []core.TopologySpreadConstraint{{
@ -22670,7 +22699,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.DoNotSchedule,
MinDomains: utilpointer.Int32(-1),
}},
wantFieldErrors: []*field.Error{field.Invalid(fieldPathMinDomains, utilpointer.Int32(-1), isNotPositiveErrorMsg)},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMinDomains, nil, "").WithOrigin("minimum"),
},
}, {
name: "cannot use non-nil MinDomains with ScheduleAnyway",
constraints: []core.TopologySpreadConstraint{{
@ -22679,7 +22710,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.ScheduleAnyway,
MinDomains: utilpointer.Int32(10),
}},
wantFieldErrors: []*field.Error{field.Invalid(fieldPathMinDomains, utilpointer.Int32(10), fmt.Sprintf("can only use minDomains if whenUnsatisfiable=%s, not %s", string(core.DoNotSchedule), string(core.ScheduleAnyway)))},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMinDomains, nil, "").WithOrigin("dependsOn"),
},
}, {
name: "use negative MinDomains with ScheduleAnyway(invalid)",
constraints: []core.TopologySpreadConstraint{{
@ -22688,50 +22721,58 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.ScheduleAnyway,
MinDomains: utilpointer.Int32(-1),
}},
wantFieldErrors: []*field.Error{
field.Invalid(fieldPathMinDomains, utilpointer.Int32(-1), isNotPositiveErrorMsg),
field.Invalid(fieldPathMinDomains, utilpointer.Int32(-1), fmt.Sprintf("can only use minDomains if whenUnsatisfiable=%s, not %s", string(core.DoNotSchedule), string(core.ScheduleAnyway))),
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMinDomains, nil, "").WithOrigin("minimum"),
field.Invalid(fieldPathMinDomains, nil, "").WithOrigin("dependsOn"),
},
}, {
name: "missing TopologyKey",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, WhenUnsatisfiable: core.DoNotSchedule},
},
wantFieldErrors: []*field.Error{field.Required(fieldPathTopologyKey, "can not be empty")},
wantFieldErrors: field.ErrorList{
field.Required(fieldPathTopologyKey, ""),
},
}, {
name: "missing scheduling mode",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, TopologyKey: "k8s.io/zone"},
},
wantFieldErrors: []*field.Error{field.NotSupported(fieldPathWhenUnsatisfiable, core.UnsatisfiableConstraintAction(""), sets.List(supportedScheduleActions))},
wantFieldErrors: field.ErrorList{
field.NotSupported[core.UnsatisfiableConstraintAction](fieldPathWhenUnsatisfiable, nil, nil),
},
}, {
name: "unsupported scheduling mode",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.UnsatisfiableConstraintAction("N/A")},
},
wantFieldErrors: []*field.Error{field.NotSupported(fieldPathWhenUnsatisfiable, core.UnsatisfiableConstraintAction("N/A"), sets.List(supportedScheduleActions))},
wantFieldErrors: field.ErrorList{
field.NotSupported[core.UnsatisfiableConstraintAction](fieldPathWhenUnsatisfiable, nil, nil),
},
}, {
name: "multiple constraints ok with all required fields",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
{MaxSkew: 2, TopologyKey: "k8s.io/node", WhenUnsatisfiable: core.ScheduleAnyway},
},
wantFieldErrors: field.ErrorList{},
wantFieldErrors: nil,
}, {
name: "multiple constraints missing TopologyKey on partial ones",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, WhenUnsatisfiable: core.ScheduleAnyway},
{MaxSkew: 2, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
},
wantFieldErrors: []*field.Error{field.Required(fieldPathTopologyKey, "can not be empty")},
wantFieldErrors: field.ErrorList{
field.Required(fieldPathTopologyKey, ""),
},
}, {
name: "duplicate constraints",
constraints: []core.TopologySpreadConstraint{
{MaxSkew: 1, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
{MaxSkew: 2, TopologyKey: "k8s.io/zone", WhenUnsatisfiable: core.DoNotSchedule},
},
wantFieldErrors: []*field.Error{
field.Duplicate(fieldPathTopologyKeyAndWhenUnsatisfiable, fmt.Sprintf("{%v, %v}", "k8s.io/zone", core.DoNotSchedule)),
wantFieldErrors: field.ErrorList{
field.Duplicate(fieldPathTopologyKeyAndWhenUnsatisfiable, nil),
},
}, {
name: "supported policy name set on NodeAffinityPolicy and NodeTaintsPolicy",
@ -22742,7 +22783,7 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
NodeAffinityPolicy: &honor,
NodeTaintsPolicy: &ignore,
}},
wantFieldErrors: []*field.Error{},
wantFieldErrors: nil,
}, {
name: "unsupported policy name set on NodeAffinityPolicy",
constraints: []core.TopologySpreadConstraint{{
@ -22752,8 +22793,8 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
NodeAffinityPolicy: &unknown,
NodeTaintsPolicy: &ignore,
}},
wantFieldErrors: []*field.Error{
field.NotSupported(nodeAffinityField, &unknown, sets.List(supportedPodTopologySpreadNodePolicies)),
wantFieldErrors: field.ErrorList{
field.NotSupported[core.NodeInclusionPolicy](nodeAffinityField, nil, nil),
},
}, {
name: "unsupported policy name set on NodeTaintsPolicy",
@ -22764,8 +22805,8 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
NodeAffinityPolicy: &honor,
NodeTaintsPolicy: &unknown,
}},
wantFieldErrors: []*field.Error{
field.NotSupported(nodeTaintsField, &unknown, sets.List(supportedPodTopologySpreadNodePolicies)),
wantFieldErrors: field.ErrorList{
field.NotSupported[core.NodeInclusionPolicy](nodeTaintsField, nil, nil),
},
}, {
name: "key in MatchLabelKeys isn't correctly defined",
@ -22776,7 +22817,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.DoNotSchedule,
MatchLabelKeys: []string{"/simple"},
}},
wantFieldErrors: field.ErrorList{field.Invalid(fieldPathMatchLabelKeys.Index(0), "/simple", "prefix part must be non-empty")},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMatchLabelKeys.Index(0), nil, "").WithOrigin("labelKey"),
},
}, {
name: "key exists in both matchLabelKeys and labelSelector",
constraints: []core.TopologySpreadConstraint{{
@ -22794,7 +22837,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
},
},
}},
wantFieldErrors: field.ErrorList{field.Invalid(fieldPathMatchLabelKeys.Index(0), "foo", "exists in both matchLabelKeys and labelSelector")},
wantFieldErrors: field.ErrorList{
field.Invalid(fieldPathMatchLabelKeys.Index(0), nil, "").WithOrigin("overlappingKeys"),
},
}, {
name: "key in MatchLabelKeys is forbidden to be specified when labelSelector is not set",
constraints: []core.TopologySpreadConstraint{{
@ -22803,7 +22848,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
WhenUnsatisfiable: core.DoNotSchedule,
MatchLabelKeys: []string{"foo"},
}},
wantFieldErrors: field.ErrorList{field.Forbidden(fieldPathMatchLabelKeys, "must not be specified when labelSelector is not set")},
wantFieldErrors: field.ErrorList{
field.Forbidden(fieldPathMatchLabelKeys, ""),
},
}, {
name: "invalid matchLabels set on labelSelector when AllowInvalidTopologySpreadConstraintLabelSelector is false",
constraints: []core.TopologySpreadConstraint{{
@ -22813,7 +22860,9 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
MinDomains: nil,
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"NoUppercaseOrSpecialCharsLike=Equals": "foo"}},
}},
wantFieldErrors: []*field.Error{field.Invalid(labelSelectorField.Child("matchLabels"), "NoUppercaseOrSpecialCharsLike=Equals", "name part must consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character (e.g. 'MyName', or 'my.name', or '123-abc', regex used for validation is '([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]')")},
wantFieldErrors: field.ErrorList{
field.Invalid(labelSelectorField.Child("matchLabels"), nil, "").WithOrigin("labelKey"),
},
opts: PodValidationOptions{AllowInvalidTopologySpreadConstraintLabelSelector: false},
}, {
name: "invalid matchLabels set on labelSelector when AllowInvalidTopologySpreadConstraintLabelSelector is true",
@ -22824,7 +22873,7 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
MinDomains: nil,
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"NoUppercaseOrSpecialCharsLike=Equals": "foo"}},
}},
wantFieldErrors: []*field.Error{},
wantFieldErrors: nil,
opts: PodValidationOptions{AllowInvalidTopologySpreadConstraintLabelSelector: true},
}, {
name: "valid matchLabels set on labelSelector when AllowInvalidTopologySpreadConstraintLabelSelector is false",
@ -22835,17 +22884,15 @@ func TestValidateTopologySpreadConstraints(t *testing.T) {
MinDomains: nil,
LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"foo": "foo"}},
}},
wantFieldErrors: []*field.Error{},
wantFieldErrors: nil,
opts: PodValidationOptions{AllowInvalidTopologySpreadConstraintLabelSelector: false},
},
}
}}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
errs := validateTopologySpreadConstraints(tc.constraints, fieldPath, tc.opts)
if diff := cmp.Diff(tc.wantFieldErrors, errs); diff != "" {
t.Errorf("unexpected field errors (-want, +got):\n%s", diff)
}
matcher := fldtest.Match().ByType().ByField().ByOrigin().RequireOriginWhenInvalid()
fldtest.MatchErrors(t, tc.wantFieldErrors, errs, matcher)
})
}
}

View File

@ -21,6 +21,8 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@ -517,7 +519,10 @@ func TestValidateClientConnectionConfiguration(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
errs := validateClientConnectionConfiguration(testCase.ccc, newPath)
assert.Equal(t, testCase.expectedErrs, errs, "did not get expected validation errors")
cmpOpts := []cmp.Option{cmpopts.IgnoreFields(field.Error{}, "Origin"), cmpopts.SortSlices(func(a, b *field.Error) bool { return a.Error() < b.Error() })}
if diff := cmp.Diff(testCase.expectedErrs, errs, cmpOpts...); diff != "" {
t.Errorf("did not get expected validation errors (-want,+got):\n%s", diff)
}
})
}
}

View File

@ -104,7 +104,7 @@ func ValidateLabelSelectorRequirement(sr metav1.LabelSelectorRequirement, opts L
func ValidateLabelName(labelName string, fldPath *field.Path) field.ErrorList {
allErrs := field.ErrorList{}
for _, msg := range validation.IsQualifiedName(labelName) {
allErrs = append(allErrs, field.Invalid(fldPath, labelName, msg))
allErrs = append(allErrs, field.Invalid(fldPath, labelName, msg).WithOrigin("labelKey"))
}
return allErrs
}

View File

@ -52,8 +52,8 @@ type Error struct {
var _ error = &Error{}
// Error implements the error interface.
func (v *Error) Error() string {
return fmt.Sprintf("%s: %s", v.Field, v.ErrorBody())
func (e *Error) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.ErrorBody())
}
type OmitValueType struct{}
@ -62,21 +62,21 @@ var omitValue = OmitValueType{}
// ErrorBody returns the error message without the field name. This is useful
// for building nice-looking higher-level error reporting.
func (v *Error) ErrorBody() string {
func (e *Error) ErrorBody() string {
var s string
switch {
case v.Type == ErrorTypeRequired:
s = v.Type.String()
case v.Type == ErrorTypeForbidden:
s = v.Type.String()
case v.Type == ErrorTypeTooLong:
s = v.Type.String()
case v.Type == ErrorTypeInternal:
s = v.Type.String()
case v.BadValue == omitValue:
s = v.Type.String()
case e.Type == ErrorTypeRequired:
s = e.Type.String()
case e.Type == ErrorTypeForbidden:
s = e.Type.String()
case e.Type == ErrorTypeTooLong:
s = e.Type.String()
case e.Type == ErrorTypeInternal:
s = e.Type.String()
case e.BadValue == omitValue:
s = e.Type.String()
default:
value := v.BadValue
value := e.BadValue
valueType := reflect.TypeOf(value)
if value == nil || valueType == nil {
value = "null"
@ -90,30 +90,30 @@ func (v *Error) ErrorBody() string {
switch t := value.(type) {
case int64, int32, float64, float32, bool:
// use simple printer for simple types
s = fmt.Sprintf("%s: %v", v.Type, value)
s = fmt.Sprintf("%s: %v", e.Type, value)
case string:
s = fmt.Sprintf("%s: %q", v.Type, t)
s = fmt.Sprintf("%s: %q", e.Type, t)
case fmt.Stringer:
// anything that defines String() is better than raw struct
s = fmt.Sprintf("%s: %s", v.Type, t.String())
s = fmt.Sprintf("%s: %s", e.Type, t.String())
default:
// fallback to raw struct
// TODO: internal types have panic guards against json.Marshalling to prevent
// accidental use of internal types in external serialized form. For now, use
// %#v, although it would be better to show a more expressive output in the future
s = fmt.Sprintf("%s: %#v", v.Type, value)
s = fmt.Sprintf("%s: %#v", e.Type, value)
}
}
if len(v.Detail) != 0 {
s += fmt.Sprintf(": %s", v.Detail)
if len(e.Detail) != 0 {
s += fmt.Sprintf(": %s", e.Detail)
}
return s
}
// WithOrigin adds origin information to the FieldError
func (v *Error) WithOrigin(o string) *Error {
v.Origin = o
return v
func (e *Error) WithOrigin(o string) *Error {
e.Origin = o
return e
}
// ErrorType is a machine readable value providing more detail about why

View File

@ -0,0 +1,203 @@
/*
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 (
"fmt"
"reflect"
"regexp"
"strings"
"testing"
field "k8s.io/apimachinery/pkg/util/validation/field"
)
// MatchErrors compares two ErrorLists with the specified matcher, and fails
// the test if they don't match. If a given "want" error matches multiple "got"
// errors, they will all be consumed. This might be OK (e.g. if there are
// multiple errors on the same field from the same origin) or it might be an
// insufficiently specific matcher, so these will be logged.
func MatchErrors(t *testing.T, want, got field.ErrorList, matcher *Matcher) {
t.Helper()
remaining := got
for _, w := range want {
tmp := make(field.ErrorList, 0, len(remaining))
n := 0
for _, g := range remaining {
if matcher.Matches(w, g) {
n++
} else {
tmp = append(tmp, g)
}
}
if n == 0 {
t.Errorf("expected an error matching:\n%s", matcher.Render(w))
} else if n > 1 {
// This is not necessarily and error, but it's worth logging in
// case it's not what the test author intended.
t.Logf("multiple errors matched:\n%s", matcher.Render(w))
}
remaining = tmp
}
if len(remaining) > 0 {
for _, e := range remaining {
t.Errorf("unmatched error:\n%s", Match().Exactly().Render(e))
}
}
}
// Match returns a new Matcher.
func Match() *Matcher {
return &Matcher{}
}
// Matcher is a helper for comparing field.Error objects.
type Matcher struct {
matchType bool
matchField bool
matchValue bool
matchOrigin bool
matchDetail func(want, got string) bool
requireOriginWhenInvalid bool
}
// Matches returns true if the two field.Error objects match according to the
// configured criteria.
func (m *Matcher) Matches(want, got *field.Error) bool {
if m.matchType && want.Type != got.Type {
return false
}
if m.matchField && want.Field != got.Field {
return false
}
if m.matchValue && !reflect.DeepEqual(want.BadValue, got.BadValue) {
return false
}
if m.matchOrigin {
if want.Origin != got.Origin {
return false
}
if m.requireOriginWhenInvalid && want.Type == field.ErrorTypeInvalid {
if want.Origin == "" || got.Origin == "" {
return false
}
}
}
if m.matchDetail != nil && !m.matchDetail(want.Detail, got.Detail) {
return false
}
return true
}
// Render returns a string representation of the specified Error object,
// according to the criteria configured in the Matcher.
func (m *Matcher) Render(e *field.Error) string {
buf := strings.Builder{}
comma := func() {
if buf.Len() > 0 {
buf.WriteString(", ")
}
}
if m.matchType {
comma()
buf.WriteString(fmt.Sprintf("Type=%q", e.Type))
}
if m.matchField {
comma()
buf.WriteString(fmt.Sprintf("Field=%q", e.Field))
}
if m.matchValue {
comma()
buf.WriteString(fmt.Sprintf("Value=%v", e.BadValue))
}
if m.matchOrigin || m.requireOriginWhenInvalid && e.Type == field.ErrorTypeInvalid {
comma()
buf.WriteString(fmt.Sprintf("Origin=%q", e.Origin))
}
if m.matchDetail != nil {
comma()
buf.WriteString(fmt.Sprintf("Detail=%q", e.Detail))
}
return "{" + buf.String() + "}"
}
// Exactly configures the matcher to match all fields exactly.
func (m *Matcher) Exactly() *Matcher {
return m.ByType().ByField().ByValue().ByOrigin().ByDetailExact()
}
// Exactly configures the matcher to match errors by type.
func (m *Matcher) ByType() *Matcher {
m.matchType = true
return m
}
// ByField configures the matcher to match errors by field path.
func (m *Matcher) ByField() *Matcher {
m.matchField = true
return m
}
// ByValue configures the matcher to match errors by the errant value.
func (m *Matcher) ByValue() *Matcher {
m.matchValue = true
return m
}
// ByOrigin configures the matcher to match errors by the origin.
func (m *Matcher) ByOrigin() *Matcher {
m.matchOrigin = true
return m
}
// RequireOriginWhenInvalid configures the matcher to require the Origin field
// to be set when the Type is Invalid and the matcher is matching by Origin.
func (m *Matcher) RequireOriginWhenInvalid() *Matcher {
m.requireOriginWhenInvalid = true
return m
}
// ByDetailExact configures the matcher to match errors by the exact detail
// string.
func (m *Matcher) ByDetailExact() *Matcher {
m.matchDetail = func(want, got string) bool {
return got == want
}
return m
}
// ByDetailSubstring configures the matcher to match errors by a substring of
// the detail string.
func (m *Matcher) ByDetailSubstring() *Matcher {
m.matchDetail = func(want, got string) bool {
return strings.Contains(got, want)
}
return m
}
// ByDetailRegexp configures the matcher to match errors by a regular
// expression of the detail string, where the "want" string is assumed to be a
// valid regular expression.
func (m *Matcher) ByDetailRegexp() *Matcher {
m.matchDetail = func(want, got string) bool {
return regexp.MustCompile(want).MatchString(got)
}
return m
}