Add declarative validation to scheme

This commit is contained in:
Joe Betz 2025-03-03 19:36:50 -05:00
parent a5dda5d879
commit 5ff334a158
2 changed files with 143 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 }