Merge pull request #130549 from jpbetz/validation-gen-pr2

KEP-5073: Add declarative validation to scheme
This commit is contained in:
Kubernetes Prow Robot
2025-03-04 14:50:02 -08:00
committed by GitHub
4 changed files with 434 additions and 0 deletions

View File

@@ -17,15 +17,18 @@ limitations under the License.
package runtime
import (
"context"
"fmt"
"reflect"
"strings"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/naming"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
)
// Scheme defines methods for serializing and deserializing API objects, a type
@@ -68,6 +71,12 @@ type Scheme struct {
// the provided object must be a pointer.
defaulterFuncs map[reflect.Type]func(interface{})
// validationFuncs is a map to funcs to be called with an object to perform validation.
// The provided object must be a pointer.
// If oldObject is non-nil, update validation is performed and may perform additional
// validation such as transition rules and immutability checks.
validationFuncs map[reflect.Type]func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresources ...string) field.ErrorList
// converter stores all registered conversion functions. It also has
// default converting behavior.
converter *conversion.Converter
@@ -96,6 +105,7 @@ func NewScheme() *Scheme {
unversionedKinds: map[string]reflect.Type{},
fieldLabelConversionFuncs: map[schema.GroupVersionKind]FieldLabelConversionFunc{},
defaulterFuncs: map[reflect.Type]func(interface{}){},
validationFuncs: map[reflect.Type]func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresource ...string) field.ErrorList{},
versionPriority: map[string][]string{},
schemeName: naming.GetNameFromCallsite(internalPackages...),
}
@@ -347,6 +357,35 @@ func (s *Scheme) Default(src Object) {
}
}
// AddValidationFunc registered a function that can validate the object, and
// oldObject. These functions will be invoked when Validate() or ValidateUpdate()
// is called. The function will never be called unless the validated object
// matches srcType. If this function is invoked twice with the same srcType, the
// fn passed to the later call will be used instead.
func (s *Scheme) AddValidationFunc(srcType Object, fn func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresources ...string) field.ErrorList) {
s.validationFuncs[reflect.TypeOf(srcType)] = fn
}
// Validate validates the provided Object according to the generated declarative validation code.
// WARNING: This does not validate all objects! The handwritten validation code in validation.go
// is not run when this is called. Only the generated zz_generated.validations.go validation code is run.
func (s *Scheme) Validate(ctx context.Context, options sets.Set[string], object Object, subresources ...string) field.ErrorList {
if fn, ok := s.validationFuncs[reflect.TypeOf(object)]; ok {
return fn(ctx, operation.Operation{Type: operation.Create, Options: options}, object, nil, subresources...)
}
return nil
}
// ValidateUpdate validates the provided object and oldObject according to the generated declarative validation code.
// WARNING: This does not validate all objects! The handwritten validation code in validation.go
// is not run when this is called. Only the generated zz_generated.validations.go validation code is run.
func (s *Scheme) ValidateUpdate(ctx context.Context, options sets.Set[string], object, oldObject Object, subresources ...string) field.ErrorList {
if fn, ok := s.validationFuncs[reflect.TypeOf(object)]; ok {
return fn(ctx, operation.Operation{Type: operation.Update, Options: options}, object, oldObject, subresources...)
}
return nil
}
// Convert will attempt to convert in into out. Both must be pointers. For easy
// testing of conversion functions. Returns an error if the conversion isn't
// possible. You can call this with types that haven't been registered (for example,

View File

@@ -17,12 +17,15 @@ limitations under the License.
package runtime_test
import (
"context"
"fmt"
"reflect"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/conversion"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -30,6 +33,9 @@ import (
runtimetesting "k8s.io/apimachinery/pkg/runtime/testing"
"k8s.io/apimachinery/pkg/util/diff"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
fieldtesting "k8s.io/apimachinery/pkg/util/validation/field/testing"
)
type testConversions struct {
@@ -1009,3 +1015,101 @@ func TestMetaValuesUnregisteredConvert(t *testing.T) {
t.Errorf("Expected %v, got %v", e, a)
}
}
func TestRegisterValidate(t *testing.T) {
invalidValue := field.Invalid(field.NewPath("testString"), "", "Invalid value").WithOrigin("invalid-value")
invalidLength := field.Invalid(field.NewPath("testString"), "", "Invalid length").WithOrigin("invalid-length")
invalidStatusErr := field.Invalid(field.NewPath("testString"), "", "Invalid condition").WithOrigin("invalid-condition")
invalidIfOptionErr := field.Invalid(field.NewPath("testString"), "", "Invalid when option is set").WithOrigin("invalid-when-option-set")
testCases := []struct {
name string
object runtime.Object
oldObject runtime.Object
subresource []string
options sets.Set[string]
expected field.ErrorList
}{
{
name: "single error",
object: &TestType1{},
expected: field.ErrorList{invalidValue},
},
{
name: "multiple errors",
object: &TestType2{},
expected: field.ErrorList{invalidValue, invalidLength},
},
{
name: "update error",
object: &TestType2{},
oldObject: &TestType2{},
expected: field.ErrorList{invalidLength},
},
{
name: "options error",
object: &TestType1{},
options: sets.New("option1"),
expected: field.ErrorList{invalidIfOptionErr},
},
{
name: "subresource error",
object: &TestType1{},
subresource: []string{"status"},
expected: field.ErrorList{invalidStatusErr},
},
}
s := runtime.NewScheme()
ctx := context.Background()
// register multiple types for testing to ensure registration is working as expected
s.AddValidationFunc(&TestType1{}, func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresources ...string) field.ErrorList {
if op.Options.Has("option1") {
return field.ErrorList{invalidIfOptionErr}
}
if len(subresources) == 1 && subresources[0] == "status" {
return field.ErrorList{invalidStatusErr}
}
return field.ErrorList{invalidValue}
})
s.AddValidationFunc(&TestType2{}, func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresources ...string) field.ErrorList {
if oldObject != nil {
return field.ErrorList{invalidLength}
}
return field.ErrorList{invalidValue, invalidLength}
})
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var results field.ErrorList
if tc.oldObject == nil {
results = s.Validate(ctx, tc.options, tc.object, tc.subresource...)
} else {
results = s.ValidateUpdate(ctx, tc.options, tc.object, tc.oldObject, tc.subresource...)
}
fieldtesting.MatchErrors(t, tc.expected, results, fieldtesting.Match().ByType().ByField().ByOrigin())
})
}
}
type TestType1 struct {
Version string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
TestString string `json:"testString"`
}
func (TestType1) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind }
func (TestType1) DeepCopyObject() runtime.Object { return nil }
type TestType2 struct {
Version string `json:"apiVersion,omitempty"`
Kind string `json:"kind,omitempty"`
TestString string `json:"testString"`
}
func (TestType2) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind }
func (TestType2) DeepCopyObject() runtime.Object { return nil }

View File

@@ -0,0 +1,108 @@
/*
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 (
"context"
"fmt"
"strings"
"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"
)
// ValidateDeclaratively validates obj against declarative validation tags
// defined in its Go type. It uses the API version extracted from ctx and the
// provided scheme for validation.
//
// The ctx MUST contain requestInfo, which determines the target API for
// validation. The obj is converted to the API version using the provided scheme
// before validation occurs. The scheme MUST have the declarative validation
// registered for the requested resource/subresource.
//
// option should contain any validation options that the declarative validation
// tags expect.
//
// Returns a field.ErrorList containing any validation errors. An internal error
// is included if requestInfo is missing from the context or if version
// conversion fails.
func ValidateDeclaratively(ctx context.Context, options sets.Set[string], scheme *runtime.Scheme, obj runtime.Object) field.ErrorList {
if requestInfo, found := genericapirequest.RequestInfoFrom(ctx); found {
groupVersion := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion}
versionedObj, err := scheme.ConvertToVersion(obj, groupVersion)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
}
subresources, err := parseSubresourcePath(requestInfo.Subresource)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", err))}
}
return scheme.Validate(ctx, options, versionedObj, subresources...)
} else {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("could not find requestInfo in context"))}
}
}
// ValidateUpdateDeclaratively validates obj and oldObj against declarative
// validation tags defined in its Go type. It uses the API version extracted from
// ctx and the provided scheme for validation.
//
// The ctx MUST contain requestInfo, which determines the target API for
// validation. The obj is converted to the API version using the provided scheme
// before validation occurs. The scheme MUST have the declarative validation
// registered for the requested resource/subresource.
//
// option should contain any validation options that the declarative validation
// tags expect.
//
// Returns a field.ErrorList containing any validation errors. An internal error
// is included if requestInfo is missing from the context or if version
// conversion fails.
func ValidateUpdateDeclaratively(ctx context.Context, options sets.Set[string], scheme *runtime.Scheme, obj, oldObj runtime.Object) field.ErrorList {
if requestInfo, found := genericapirequest.RequestInfoFrom(ctx); found {
groupVersion := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion}
versionedObj, err := scheme.ConvertToVersion(obj, groupVersion)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
}
versionedOldObj, err := scheme.ConvertToVersion(oldObj, groupVersion)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
}
subresources, err := parseSubresourcePath(requestInfo.Subresource)
if err != nil {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", err))}
}
return scheme.ValidateUpdate(ctx, options, versionedObj, versionedOldObj, subresources...)
} else {
return field.ErrorList{field.InternalError(nil, fmt.Errorf("could not find requestInfo in context"))}
}
}
func parseSubresourcePath(subresourcePath string) ([]string, error) {
if len(subresourcePath) == 0 {
return nil, nil
}
if subresourcePath[0] != '/' {
return nil, fmt.Errorf("invalid subresource path: %s", subresourcePath)
}
parts := strings.Split(subresourcePath[1:], "/")
return parts, nil
}

View File

@@ -0,0 +1,183 @@
/*
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 (
"context"
"fmt"
"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"
fieldtesting "k8s.io/apimachinery/pkg/util/validation/field/testing"
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
)
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",
subresource: "/status",
object: valid,
oldObject: valid,
expected: field.ErrorList{invalidStatusErr},
},
{
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{}, subresources ...string) field.ErrorList {
results := field.ErrorList{}
if op.Options.Has("option1") {
results = append(results, invalidIfOptionErr)
}
if len(subresources) == 1 && 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)
}
fieldtesting.MatchErrors(t, tc.expected, results, fieldtesting.Match().ByType().ByField().ByOrigin())
})
}
}
// 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,
}
}