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, + } +}