From 5ff334a1589611c139a03238803ba5abf090eebd Mon Sep 17 00:00:00 2001 From: Joe Betz Date: Mon, 3 Mar 2025 19:36:50 -0500 Subject: [PATCH] Add declarative validation to scheme --- .../k8s.io/apimachinery/pkg/runtime/scheme.go | 39 +++++++ .../apimachinery/pkg/runtime/scheme_test.go | 104 ++++++++++++++++++ 2 files changed, 143 insertions(+) diff --git a/staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go b/staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go index a5b116718d5..fde87f1a13e 100644 --- a/staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go +++ b/staging/src/k8s.io/apimachinery/pkg/runtime/scheme.go @@ -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, diff --git a/staging/src/k8s.io/apimachinery/pkg/runtime/scheme_test.go b/staging/src/k8s.io/apimachinery/pkg/runtime/scheme_test.go index ff61024c960..d6e18052e5f 100644 --- a/staging/src/k8s.io/apimachinery/pkg/runtime/scheme_test.go +++ b/staging/src/k8s.io/apimachinery/pkg/runtime/scheme_test.go @@ -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 }