Skip to content

Commit ffc1b32

Browse files
committed
Add declarative validation utility for use from strategies
1 parent 5ff334a commit ffc1b32

File tree

2 files changed

+291
-0
lines changed

2 files changed

+291
-0
lines changed
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package rest
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"strings"
23+
24+
"k8s.io/apimachinery/pkg/runtime"
25+
"k8s.io/apimachinery/pkg/runtime/schema"
26+
"k8s.io/apimachinery/pkg/util/sets"
27+
"k8s.io/apimachinery/pkg/util/validation/field"
28+
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
29+
)
30+
31+
// ValidateDeclaratively validates obj against declarative validation tags
32+
// defined in its Go type. It uses the API version extracted from ctx and the
33+
// provided scheme for validation.
34+
//
35+
// The ctx MUST contain requestInfo, which determines the target API for
36+
// validation. The obj is converted to the API version using the provided scheme
37+
// before validation occurs. The scheme MUST have the declarative validation
38+
// registered for the requested resource/subresource.
39+
//
40+
// option should contain any validation options that the declarative validation
41+
// tags expect.
42+
//
43+
// Returns a field.ErrorList containing any validation errors. An internal error
44+
// is included if requestInfo is missing from the context or if version
45+
// conversion fails.
46+
func ValidateDeclaratively(ctx context.Context, options sets.Set[string], scheme *runtime.Scheme, obj runtime.Object) field.ErrorList {
47+
if requestInfo, found := genericapirequest.RequestInfoFrom(ctx); found {
48+
groupVersion := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion}
49+
versionedObj, err := scheme.ConvertToVersion(obj, groupVersion)
50+
if err != nil {
51+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
52+
}
53+
subresources, err := parseSubresourcePath(requestInfo.Subresource)
54+
if err != nil {
55+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", err))}
56+
}
57+
return scheme.Validate(ctx, options, versionedObj, subresources...)
58+
} else {
59+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("could not find requestInfo in context"))}
60+
}
61+
}
62+
63+
// ValidateUpdateDeclaratively validates obj and oldObj against declarative
64+
// validation tags defined in its Go type. It uses the API version extracted from
65+
// ctx and the provided scheme for validation.
66+
//
67+
// The ctx MUST contain requestInfo, which determines the target API for
68+
// validation. The obj is converted to the API version using the provided scheme
69+
// before validation occurs. The scheme MUST have the declarative validation
70+
// registered for the requested resource/subresource.
71+
//
72+
// option should contain any validation options that the declarative validation
73+
// tags expect.
74+
//
75+
// Returns a field.ErrorList containing any validation errors. An internal error
76+
// is included if requestInfo is missing from the context or if version
77+
// conversion fails.
78+
func ValidateUpdateDeclaratively(ctx context.Context, options sets.Set[string], scheme *runtime.Scheme, obj, oldObj runtime.Object) field.ErrorList {
79+
if requestInfo, found := genericapirequest.RequestInfoFrom(ctx); found {
80+
groupVersion := schema.GroupVersion{Group: requestInfo.APIGroup, Version: requestInfo.APIVersion}
81+
versionedObj, err := scheme.ConvertToVersion(obj, groupVersion)
82+
if err != nil {
83+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
84+
}
85+
versionedOldObj, err := scheme.ConvertToVersion(oldObj, groupVersion)
86+
if err != nil {
87+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error converting to versioned type: %w", err))}
88+
}
89+
subresources, err := parseSubresourcePath(requestInfo.Subresource)
90+
if err != nil {
91+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", err))}
92+
}
93+
return scheme.ValidateUpdate(ctx, options, versionedObj, versionedOldObj, subresources...)
94+
} else {
95+
return field.ErrorList{field.InternalError(nil, fmt.Errorf("could not find requestInfo in context"))}
96+
}
97+
}
98+
99+
func parseSubresourcePath(subresourcePath string) ([]string, error) {
100+
if len(subresourcePath) == 0 {
101+
return nil, nil
102+
}
103+
if subresourcePath[0] != '/' {
104+
return nil, fmt.Errorf("invalid subresource path: %s", subresourcePath)
105+
}
106+
parts := strings.Split(subresourcePath[1:], "/")
107+
return parts, nil
108+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/*
2+
Copyright 2025 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package rest
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"testing"
23+
24+
v1 "k8s.io/api/core/v1"
25+
"k8s.io/apimachinery/pkg/api/operation"
26+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
27+
"k8s.io/apimachinery/pkg/conversion"
28+
"k8s.io/apimachinery/pkg/runtime"
29+
"k8s.io/apimachinery/pkg/runtime/schema"
30+
"k8s.io/apimachinery/pkg/util/sets"
31+
"k8s.io/apimachinery/pkg/util/validation/field"
32+
fieldtesting "k8s.io/apimachinery/pkg/util/validation/field/testing"
33+
genericapirequest "k8s.io/apiserver/pkg/endpoints/request"
34+
)
35+
36+
func TestValidateDeclaratively(t *testing.T) {
37+
valid := &Pod{
38+
TypeMeta: metav1.TypeMeta{
39+
APIVersion: "v1",
40+
Kind: "Pod",
41+
},
42+
ObjectMeta: metav1.ObjectMeta{
43+
Name: "test",
44+
},
45+
}
46+
47+
invalidRestartPolicy := &Pod{
48+
TypeMeta: metav1.TypeMeta{
49+
APIVersion: "v1",
50+
Kind: "Pod",
51+
},
52+
ObjectMeta: metav1.ObjectMeta{
53+
Name: "test",
54+
},
55+
RestartPolicy: "INVALID",
56+
}
57+
58+
invalidRestartPolicyErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Invalid value").WithOrigin("invalid-test")
59+
mutatedRestartPolicyErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Immutable field").WithOrigin("immutable-test")
60+
invalidStatusErr := field.Invalid(field.NewPath("status", "conditions"), "", "Invalid condition").WithOrigin("invalid-condition")
61+
invalidIfOptionErr := field.Invalid(field.NewPath("spec", "restartPolicy"), "", "Invalid when option is set").WithOrigin("invalid-when-option-set")
62+
invalidSubresourceErr := field.InternalError(nil, fmt.Errorf("unexpected error parsing subresource path: %w", fmt.Errorf("invalid subresource path: %s", "invalid/status")))
63+
64+
testCases := []struct {
65+
name string
66+
object runtime.Object
67+
oldObject runtime.Object
68+
subresource string
69+
options sets.Set[string]
70+
expected field.ErrorList
71+
}{
72+
{
73+
name: "create",
74+
object: invalidRestartPolicy,
75+
expected: field.ErrorList{invalidRestartPolicyErr},
76+
},
77+
{
78+
name: "update",
79+
object: invalidRestartPolicy,
80+
oldObject: valid,
81+
expected: field.ErrorList{invalidRestartPolicyErr, mutatedRestartPolicyErr},
82+
},
83+
{
84+
name: "update subresource",
85+
subresource: "/status",
86+
object: valid,
87+
oldObject: valid,
88+
expected: field.ErrorList{invalidStatusErr},
89+
},
90+
{
91+
name: "invalid subresource",
92+
subresource: "invalid/status",
93+
object: valid,
94+
oldObject: valid,
95+
expected: field.ErrorList{invalidSubresourceErr},
96+
},
97+
{
98+
name: "update with option",
99+
options: sets.New("option1"),
100+
object: valid,
101+
expected: field.ErrorList{invalidIfOptionErr},
102+
},
103+
}
104+
105+
ctx := context.Background()
106+
107+
internalGV := schema.GroupVersion{Group: "", Version: runtime.APIVersionInternal}
108+
v1GV := schema.GroupVersion{Group: "", Version: "v1"}
109+
110+
scheme := runtime.NewScheme()
111+
scheme.AddKnownTypes(internalGV, &Pod{})
112+
scheme.AddKnownTypes(v1GV, &v1.Pod{})
113+
114+
scheme.AddValidationFunc(&v1.Pod{}, func(ctx context.Context, op operation.Operation, object, oldObject interface{}, subresources ...string) field.ErrorList {
115+
results := field.ErrorList{}
116+
if op.Options.Has("option1") {
117+
results = append(results, invalidIfOptionErr)
118+
}
119+
if len(subresources) == 1 && subresources[0] == "status" {
120+
results = append(results, invalidStatusErr)
121+
}
122+
if op.Type == operation.Update && object.(*v1.Pod).Spec.RestartPolicy != oldObject.(*v1.Pod).Spec.RestartPolicy {
123+
results = append(results, mutatedRestartPolicyErr)
124+
}
125+
if object.(*v1.Pod).Spec.RestartPolicy == "INVALID" {
126+
results = append(results, invalidRestartPolicyErr)
127+
}
128+
return results
129+
})
130+
err := scheme.AddConversionFunc(&Pod{}, &v1.Pod{}, func(a, b interface{}, scope conversion.Scope) error {
131+
if in, ok := a.(*Pod); ok {
132+
if out, ok := b.(*v1.Pod); ok {
133+
out.APIVersion = in.APIVersion
134+
out.Kind = in.Kind
135+
out.Spec.RestartPolicy = v1.RestartPolicy(in.RestartPolicy)
136+
}
137+
}
138+
return nil
139+
})
140+
if err != nil {
141+
t.Fatal(err)
142+
}
143+
144+
for _, tc := range testCases {
145+
ctx = genericapirequest.WithRequestInfo(ctx, &genericapirequest.RequestInfo{
146+
APIGroup: "",
147+
APIVersion: "v1",
148+
Subresource: tc.subresource,
149+
})
150+
t.Run(tc.name, func(t *testing.T) {
151+
var results field.ErrorList
152+
if tc.oldObject == nil {
153+
results = ValidateDeclaratively(ctx, tc.options, scheme, tc.object)
154+
} else {
155+
results = ValidateUpdateDeclaratively(ctx, tc.options, scheme, tc.object, tc.oldObject)
156+
}
157+
fieldtesting.MatchErrors(t, tc.expected, results, fieldtesting.Match().ByType().ByField().ByOrigin())
158+
})
159+
}
160+
}
161+
162+
// Fake internal pod type, since core.Pod cannot be imported by this package
163+
type Pod struct {
164+
metav1.TypeMeta `json:",inline"`
165+
metav1.ObjectMeta `json:"metadata,omitempty"`
166+
RestartPolicy string `json:"restartPolicy"`
167+
}
168+
169+
func (Pod) GetObjectKind() schema.ObjectKind { return schema.EmptyObjectKind }
170+
171+
func (p Pod) DeepCopyObject() runtime.Object {
172+
return &Pod{
173+
TypeMeta: metav1.TypeMeta{
174+
APIVersion: p.APIVersion,
175+
Kind: p.Kind,
176+
},
177+
ObjectMeta: metav1.ObjectMeta{
178+
Name: p.Name,
179+
Namespace: p.Namespace,
180+
},
181+
RestartPolicy: p.RestartPolicy,
182+
}
183+
}

0 commit comments

Comments
 (0)