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 } diff --git a/staging/src/k8s.io/apiserver/pkg/registry/rest/validate.go b/staging/src/k8s.io/apiserver/pkg/registry/rest/validate.go new file mode 100644 index 00000000000..1fa6dc1f552 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/registry/rest/validate.go @@ -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 +} diff --git a/staging/src/k8s.io/apiserver/pkg/registry/rest/validate_test.go b/staging/src/k8s.io/apiserver/pkg/registry/rest/validate_test.go new file mode 100644 index 00000000000..df3e86efb24 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/registry/rest/validate_test.go @@ -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, + } +}