Add +k8s:ifEnabled and +k8s:ifDisabled tags

Rename if(OptionsEnabled|Disabled) -> if(Enabled|Disabled)

Drop stability

Use positional arg for option name
This commit is contained in:
Joe Betz
2025-08-28 19:01:18 -04:00
parent 243f47f3b3
commit 15b29a0fa2
6 changed files with 188 additions and 27 deletions

View File

@@ -0,0 +1,35 @@
/*
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 validate
import (
"context"
"k8s.io/apimachinery/pkg/api/operation"
"k8s.io/apimachinery/pkg/util/validation/field"
)
// IfOption conditionally evaluates a validation function. If the option and enabled are both true the validator
// is called. If the option and enabled are both false the validator is called. Otherwise, the validator is not called.
func IfOption[T any](ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T,
optionName string, enabled bool, validator func(context.Context, operation.Operation, *field.Path, *T, *T) field.ErrorList,
) field.ErrorList {
if op.HasOption(optionName) == enabled {
return validator(ctx, op, fldPath, value, oldValue)
}
return nil
}

View File

@@ -0,0 +1,52 @@
/*
Copyright 2024 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.
*/
// +k8s:validation-gen=TypeMeta
// +k8s:validation-gen-scheme-registry=k8s.io/code-generator/cmd/validation-gen/testscheme.Scheme
// This is a test package.
package options
import "k8s.io/code-generator/cmd/validation-gen/testscheme"
var localSchemeBuilder = testscheme.New()
type Struct struct {
TypeMeta int
// +k8s:ifEnabled(FeatureX)=+k8s:subfield(xEnabledField)=+k8s:validateFalse="field Struct.ObjectMeta.XEnabledField"
ObjectMeta `json:"metadata,omitempty"`
// +k8s:ifEnabled(FeatureX)=+k8s:validateFalse="field Struct.XEnabledField"
XEnabledField string `json:"xEnabledField"`
// +k8s:ifDisabled(FeatureX)=+k8s:validateFalse="field Struct.XDisabledField"
XDisabledField string `json:"xDisabledField"`
// +k8s:ifEnabled(FeatureY)=+k8s:validateFalse="field Struct.YEnabledField"
YEnabledField string `json:"yEnabledField"`
// +k8s:ifDisabled(FeatureY)=+k8s:validateFalse="field Struct.YDisabledField"
YDisabledField string `json:"yDisabledField"`
// +k8s:ifEnabled(FeatureX)=+k8s:validateFalse="field Struct.XYMixedField/X"
// +k8s:ifDisabled(FeatureY)=+k8s:validateFalse="field Struct.XYMixedField/Y"
XYMixedField string `json:"xyMixedField"`
}
type ObjectMeta struct {
XEnabledField string `json:"xEnabledField"`
}

View File

@@ -0,0 +1,64 @@
/*
Copyright 2024 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 options
import (
"testing"
)
func Test(t *testing.T) {
st := localSchemeBuilder.Test(t)
st.Value(&Struct{
// All zero values
}).ExpectValidateFalseByPath(map[string][]string{
// All ifDisabled validations should trigger
"xDisabledField": {"field Struct.XDisabledField"},
"yDisabledField": {"field Struct.YDisabledField"},
"xyMixedField": {"field Struct.XYMixedField/Y"},
})
st.Value(&Struct{
// All zero values
}).Opts([]string{"FeatureX", "FeatureY"}).ExpectValidateFalseByPath(map[string][]string{
// All ifEnabled validations should trigger
"metadata.xEnabledField": {"field Struct.ObjectMeta.XEnabledField"},
"xEnabledField": {"field Struct.XEnabledField"},
"yEnabledField": {"field Struct.YEnabledField"},
"xyMixedField": {"field Struct.XYMixedField/X"},
})
st.Value(&Struct{
// All zero values
}).Opts([]string{"FeatureX"}).ExpectValidateFalseByPath(map[string][]string{
// All ifEnabled validations should trigger
"metadata.xEnabledField": {"field Struct.ObjectMeta.XEnabledField"},
"xEnabledField": {"field Struct.XEnabledField"},
"yDisabledField": {"field Struct.YDisabledField"},
"xyMixedField": {
"field Struct.XYMixedField/X",
"field Struct.XYMixedField/Y"},
})
st.Value(&Struct{
// All zero values
}).Opts([]string{"FeatureY"}).ExpectValidateFalseByPath(map[string][]string{
// All ifEnabled validations should trigger
"xDisabledField": {"field Struct.XDisabledField"},
"yEnabledField": {"field Struct.YEnabledField"},
})
}

View File

@@ -37,6 +37,7 @@ func init() { localSchemeBuilder.Register(RegisterValidations) }
// RegisterValidations adds validation functions to the given scheme.
// Public to allow building arbitrary schemes.
func RegisterValidations(scheme *testscheme.Scheme) error {
// type Struct
scheme.AddValidationFunc((*Struct)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList {
switch op.Request.SubresourcePath() {
case "/":
@@ -47,17 +48,21 @@ func RegisterValidations(scheme *testscheme.Scheme) error {
return nil
}
// Validate_Struct validates an instance of Struct according
// to declarative validation rules in the API schema.
func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *Struct) (errs field.ErrorList) {
// field Struct.TypeMeta has no validation
// field Struct.ObjectMeta
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *ObjectMeta) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureX", true, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *ObjectMeta) field.ErrorList {
return validate.Subfield(ctx, op, fldPath, obj, oldObj, "xEnabledField", func(o *ObjectMeta) *string { return &o.XEnabledField }, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.Subfield(ctx, op, fldPath, obj, oldObj, "xEnabledField", func(o *ObjectMeta) *string { return &o.XEnabledField }, validate.DirectEqualPtr, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.ObjectMeta.XEnabledField")
})
})...)
@@ -67,9 +72,11 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
// field Struct.XEnabledField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureX", true, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.XEnabledField")
})...)
@@ -79,9 +86,11 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
// field Struct.XDisabledField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureX", false, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.XDisabledField")
})...)
@@ -91,9 +100,11 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
// field Struct.YEnabledField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureY", true, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.YEnabledField")
})...)
@@ -103,9 +114,11 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
// field Struct.YDisabledField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureY", false, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.YDisabledField")
})...)
@@ -115,9 +128,11 @@ func Validate_Struct(ctx context.Context, op operation.Operation, fldPath *field
// field Struct.XYMixedField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// don't revalidate unchanged data
if op.Type == operation.Update && (obj == oldObj || (obj != nil && oldObj != nil && *obj == *oldObj)) {
return nil // no changes
return nil
}
// call field-attached validations
errs = append(errs, validate.IfOption(ctx, op, fldPath, obj, oldObj, "FeatureY", false, func(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *string) field.ErrorList {
return validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field Struct.XYMixedField/Y")
})...)

View File

@@ -80,8 +80,7 @@ func (itv ifTagValidator) GetValidations(context Context, tag codetags.Tag) (Val
func (itv ifTagValidator) Docs() TagDoc {
doc := TagDoc{
Tag: itv.TagName(),
StabilityLevel: Alpha,
Tag: itv.TagName(),
Args: []TagArgDoc{{
Description: "<option>",
Type: codetags.ArgTypeString,

View File

@@ -196,28 +196,24 @@ func (reg *registry) ExtractValidations(context Context, tags ...codetags.Tag) (
func (reg *registry) sortTagsIntoPhases(tags []codetags.Tag) [][]codetags.Tag {
// First sort all tags by their name, so the final output is deterministic.
// It is important to do this before validations are generated.
//
// It makes more sense to sort here, rather than when emitting because:
// Some tags are "meta" tags which wrap other tags. For example:
//
// Consider a type or field with the following comments:
// // +k8s:validateFalse="111"
// // +k8s:validateFalse="222"
// // +k8s:ifEnabled(Foo)=+k8s:validateFalse="333"
//
// // +k8s:validateFalse="111"
// // +k8s:validateFalse="222"
// // +k8s:ifOptionEnabled(Foo)=+k8s:validateFalse="333"
// Tag extraction will group these by tag name. The first two are
// instances of "k8s:validateFalse", while the third is an instance of
// "k8s:ifEnabled".
//
// Tag extraction will retain the relative order between 111 and 222, but
// 333 is extracted as tag "k8s:ifOptionEnabled". Those are all in a map,
// which we iterate (in a random order). When it reaches the emit stage,
// the "ifOptionEnabled" part is gone, and we will have 3 FunctionGen
// objects, all with tag "k8s:validateFalse", in a non-deterministic order
// because of the map iteration. If we sort them at that point, we won't
// have enough information to do something smart, unless we look at the
// args, which are opaque to us.
//
// Sorting it earlier means we can sort "k8s:ifOptionEnabled" against
// "k8s:validateFalse". All of the records within each of those is
// relatively ordered, so the result here would be to put "ifOptionEnabled"
// before "validateFalse" (lexicographical is better than random).
// Without sorting, the order in which tag validators are called is not defined
// (map iteration). This can lead to non-deterministic order of the generated
// validations. By sorting the tags by name first, we ensure that "k8s:ifEnabled"
// is processed before or after "k8s:validateFalse" consistently, allowing the
// "k8s:validateFalse" tags to remain grouped together. The tags for each name
// are processed in order of appearance, so relative ordering is preserved.
sortedTags := make([]codetags.Tag, len(tags))
copy(sortedTags, tags)
slices.SortFunc(sortedTags, func(a, b codetags.Tag) int {