From 15b29a0fa2035175b58e70267243aa3e06350f2a Mon Sep 17 00:00:00 2001 From: Joe Betz Date: Thu, 28 Aug 2025 19:01:18 -0400 Subject: [PATCH] Add +k8s:ifEnabled and +k8s:ifDisabled tags Rename if(OptionsEnabled|Disabled) -> if(Enabled|Disabled) Drop stability Use positional arg for option name --- .../apimachinery/pkg/api/validate/options.go | 35 ++++++++++ .../output_tests/tags/options/doc.go | 52 +++++++++++++++ .../output_tests/tags/options/doc_test.go | 64 +++++++++++++++++++ .../tags/options/zz_generated.validations.go | 29 +++++++-- .../cmd/validation-gen/validators/options.go | 3 +- .../cmd/validation-gen/validators/registry.go | 32 ++++------ 6 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/options.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc_test.go diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/options.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/options.go new file mode 100644 index 00000000000..44236550b71 --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/options.go @@ -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 +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc.go new file mode 100644 index 00000000000..45f95f7b072 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc.go @@ -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"` +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc_test.go new file mode 100644 index 00000000000..bb574676d80 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/doc_test.go @@ -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"}, + }) +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/zz_generated.validations.go index 766e876321d..1da94eda381 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/zz_generated.validations.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/options/zz_generated.validations.go @@ -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") })...) diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/options.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/options.go index 71037240764..2de800b9d8b 100644 --- a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/options.go +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/options.go @@ -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: "