Add support for validation cohorts

On a given field or type, there may be multiple "cohorts" of validation
which need to be processed together.  For example, a short-circuit
validation and a non-short-circuit for the same subfield.

The unnamed cohort (aka the default cohort) is first, followed by named
cohorts in order they are created.  Named cohorts are emitted as inline
functions, so early-return can be used.

This allows tags like k8s:subfield or k8s:item to generate cohorts
(named after the field's JSON name or the selector string,
respectively).

Subsequent commits will add support to those tags.

Co-Authored-by: Yongrui Lin <yongrlin@outlook.com>
This commit is contained in:
Tim Hockin
2025-07-31 13:30:12 -07:00
committed by yongruilin
parent 8105dbe5bc
commit efe4d17d15
8 changed files with 459 additions and 91 deletions

View File

@@ -0,0 +1,55 @@
/*
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.
*/
// +k8s:validation-gen=TypeMeta
// +k8s:validation-gen-scheme-registry=k8s.io/code-generator/cmd/validation-gen/testscheme.Scheme
// +k8s:validation-gen-test-fixture=validateFalse
// This is a test package.
package cohorts
import "k8s.io/code-generator/cmd/validation-gen/testscheme"
var localSchemeBuilder = testscheme.New()
// Note: the following are out of order on purpose.
// They should be emitted as:
// - T {ShortCircuit,Regular}
// - T c2 {ShortCircuit,Regular}
// - T c1 {ShortCircuit,Regular}
//
// +k8s:validateFalse(cohort: "c2")="type T c2 Regular"
// +k8s:validateFalse(cohort: "c1")="type T c1 Regular"
// +k8s:validateFalse(cohort: "c1", flags: "ShortCircuit")="type T c1 ShortCircuit"
// +k8s:validateFalse(cohort: "c2", flags: "ShortCircuit")="type T c2 ShortCircuit"
// +k8s:validateFalse="type T Regular"
// +k8s:validateFalse(flags: "ShortCircuit")="type T ShortCircuit"
type T struct {
TypeMeta int
// Note: the following are out of order on purpose.
// They should be emitted as:
// - T.S {ShortCircuit,Regular}
// - T.S c2 {ShortCircuit,Regular}
// - T.S c1 {ShortCircuit,Regular}
// +k8s:validateFalse(cohort: "c2")="field T.S c2 Regular"
// +k8s:validateFalse(cohort: "c1")="field T.S c1 Regular"
// +k8s:validateFalse(cohort: "c1", flags: "ShortCircuit")="field T.S c1 ShortCircuit"
// +k8s:validateFalse(cohort: "c2", flags: "ShortCircuit")="field T.S c2 ShortCircuit"
// +k8s:validateFalse="field T.S Regular"
// +k8s:validateFalse(flags: "ShortCircuit")="field T.S ShortCircuit"
S string `json:"s"`
}

View File

@@ -0,0 +1,7 @@
{
"*cohorts.T": {
"": [
"type T ShortCircuit"
]
}
}

View File

@@ -0,0 +1,107 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
/*
Copyright 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.
*/
// Code generated by validation-gen. DO NOT EDIT.
package cohorts
import (
context "context"
fmt "fmt"
operation "k8s.io/apimachinery/pkg/api/operation"
safe "k8s.io/apimachinery/pkg/api/safe"
validate "k8s.io/apimachinery/pkg/api/validate"
field "k8s.io/apimachinery/pkg/util/validation/field"
testscheme "k8s.io/code-generator/cmd/validation-gen/testscheme"
)
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 T
scheme.AddValidationFunc((*T)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList {
switch op.Request.SubresourcePath() {
case "/":
return Validate_T(ctx, op, nil /* fldPath */, obj.(*T), safe.Cast[*T](oldObj))
}
return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))}
})
return nil
}
// Validate_T validates an instance of T according
// to declarative validation rules in the API schema.
func Validate_T(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *T) (errs field.ErrorList) {
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T Regular")...)
func() { // cohort c2
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T c2 ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T c2 Regular")...)
}()
func() { // cohort c1
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T c1 ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "type T c1 Regular")...)
}()
// field T.TypeMeta has no validation
// field T.S
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
}
// call field-attached validations
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S Regular")...)
func() { // cohort c2
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S c2 ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S c2 Regular")...)
}()
func() { // cohort c1
if e := validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S c1 ShortCircuit"); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
errs = append(errs, validate.FixedResult(ctx, op, fldPath, obj, oldObj, false, "field T.S c1 Regular")...)
}()
return
}(fldPath.Child("s"), &obj.S, safe.Field(oldObj, func(oldObj *T) *string { return &oldObj.S }))...)
return errs
}

View File

@@ -0,0 +1,30 @@
//go:build !ignore_autogenerated
// +build !ignore_autogenerated
/*
Copyright 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.
*/
// Code generated by validation-gen. DO NOT EDIT.
package cohorts
import (
"testing"
)
func TestValidation(t *testing.T) {
localSchemeBuilder.Test(t).ValidateFixtures()
}

View File

@@ -1280,12 +1280,138 @@ func emitRatchetingCheck(c *generator.Context, t *types.Type, sw *generator.Snip
// variables named "obj" and "oldObj", and the field path to this value is
// named "fldPath".
func emitCallsToValidators(c *generator.Context, validations []validators.FunctionGen, sw *generator.SnippetWriter) {
// Helper func
sort := func(in []validators.FunctionGen) []validators.FunctionGen {
sooner := make([]validators.FunctionGen, 0, len(in))
later := make([]validators.FunctionGen, 0, len(in))
// Group and sort the inputs.
cohorts := sortIntoCohorts(validations)
for _, fg := range in {
for _, validations := range cohorts {
cohortName := validations[0].Cohort
if cohortName != "" {
sw.Do("func() { // cohort $.$\n", cohortName)
}
for _, v := range validations {
isShortCircuit := v.Flags.IsSet(validators.ShortCircuit)
isNonError := v.Flags.IsSet(validators.NonError)
targs := generator.Args{
"funcName": c.Universe.Type(v.Function),
"field": mkSymbolArgs(c, fieldPkgSymbols),
}
emitCall := func() {
sw.Do("$.funcName|raw$", targs)
if typeArgs := v.TypeArgs; len(typeArgs) > 0 {
sw.Do("[", nil)
for i, typeArg := range typeArgs {
sw.Do("$.|raw$", c.Universe.Type(typeArg))
if i < len(typeArgs)-1 {
sw.Do(",", nil)
}
}
sw.Do("]", nil)
}
sw.Do("(ctx, op, fldPath, obj, oldObj", targs)
for _, arg := range v.Args {
sw.Do(", ", nil)
toGolangSourceDataLiteral(sw, c, arg)
}
sw.Do(")", targs)
}
// If validation is conditional, wrap the validation function with a conditions check.
if !v.Conditions.Empty() {
emitBaseFunction := emitCall
emitCall = func() {
sw.Do("func() $.field.ErrorList|raw$ {\n", targs)
sw.Do(" if ", nil)
firstCondition := true
if len(v.Conditions.OptionEnabled) > 0 {
sw.Do("op.HasOption($.$)", strconv.Quote(v.Conditions.OptionEnabled))
firstCondition = false
}
if len(v.Conditions.OptionDisabled) > 0 {
if !firstCondition {
sw.Do(" && ", nil)
}
sw.Do("!op.HasOption($.$)", strconv.Quote(v.Conditions.OptionDisabled))
}
sw.Do(" {\n", nil)
sw.Do(" return ", nil)
emitBaseFunction()
sw.Do("\n", nil)
sw.Do(" } else {\n", nil)
sw.Do(" return nil // skip validation\n", nil)
sw.Do(" }\n", nil)
sw.Do("}()", nil)
}
}
for _, comment := range v.Comments {
sw.Do("// $.$\n", comment)
}
if isShortCircuit {
sw.Do("if e := ", nil)
emitCall()
sw.Do("; len(e) != 0 {\n", nil)
if !isNonError {
sw.Do("errs = append(errs, e...)\n", nil)
}
sw.Do(" return // do not proceed\n", nil)
sw.Do("}\n", nil)
} else {
if isNonError {
emitCall()
} else {
sw.Do("errs = append(errs, ", nil)
emitCall()
sw.Do("...)\n", nil)
}
}
}
if cohortName != "" {
sw.Do("}()\n", nil)
}
}
}
// sortIntoCohorts groups the inputs into a list of cohorts. Within each
// cohort, function calls are sorted such that short-circuiting function
// calls are handled before others. The first cohort is always the
// default cohort (named "") if it exists. Other cohorts are returned in
// the order they were defined in the input.
func sortIntoCohorts(in []validators.FunctionGen) [][]validators.FunctionGen {
defaultCohort := make([]validators.FunctionGen, 0, len(in))
namedCohorts := map[string][]validators.FunctionGen{}
idx := make([]string, 0, len(in))
for _, fg := range in {
key := fg.Cohort
if key == "" {
defaultCohort = append(defaultCohort, fg)
} else {
if !slices.Contains(idx, key) {
idx = append(idx, key)
}
namedCohorts[key] = append(namedCohorts[key], fg)
}
}
if len(defaultCohort) > 0 {
idx = append([]string{""}, idx...)
}
// NOTE: we do not sort cohorts by name, because we want to preserve
// their definition order.
result := make([][]validators.FunctionGen, 0, len(in))
for _, key := range idx {
var cohort []validators.FunctionGen
if key == "" {
cohort = defaultCohort
} else {
cohort = namedCohorts[key]
}
sooner := make([]validators.FunctionGen, 0, len(cohort))
later := make([]validators.FunctionGen, 0, len(cohort))
for _, fg := range cohort {
isShortCircuit := (fg.Flags.IsSet(validators.ShortCircuit))
if isShortCircuit {
@@ -1294,92 +1420,11 @@ func emitCallsToValidators(c *generator.Context, validations []validators.Functi
later = append(later, fg)
}
}
result := sooner
result = append(result, later...)
return result
}
validations = sort(validations)
for _, v := range validations {
isShortCircuit := v.Flags.IsSet(validators.ShortCircuit)
isNonError := v.Flags.IsSet(validators.NonError)
targs := generator.Args{
"funcName": c.Universe.Type(v.Function),
"field": mkSymbolArgs(c, fieldPkgSymbols),
}
emitCall := func() {
sw.Do("$.funcName|raw$", targs)
if typeArgs := v.TypeArgs; len(typeArgs) > 0 {
sw.Do("[", nil)
for i, typeArg := range typeArgs {
sw.Do("$.|raw$", c.Universe.Type(typeArg))
if i < len(typeArgs)-1 {
sw.Do(",", nil)
}
}
sw.Do("]", nil)
}
sw.Do("(ctx, op, fldPath, obj, oldObj", targs)
for _, arg := range v.Args {
sw.Do(", ", nil)
toGolangSourceDataLiteral(sw, c, arg)
}
sw.Do(")", targs)
}
// If validation is conditional, wrap the validation function with a conditions check.
if !v.Conditions.Empty() {
emitBaseFunction := emitCall
emitCall = func() {
sw.Do("func() $.field.ErrorList|raw$ {\n", targs)
sw.Do(" if ", nil)
firstCondition := true
if len(v.Conditions.OptionEnabled) > 0 {
sw.Do("op.HasOption($.$)", strconv.Quote(v.Conditions.OptionEnabled))
firstCondition = false
}
if len(v.Conditions.OptionDisabled) > 0 {
if !firstCondition {
sw.Do(" && ", nil)
}
sw.Do("!op.HasOption($.$)", strconv.Quote(v.Conditions.OptionDisabled))
}
sw.Do(" {\n", nil)
sw.Do(" return ", nil)
emitBaseFunction()
sw.Do("\n", nil)
sw.Do(" } else {\n", nil)
sw.Do(" return nil // skip validation\n", nil)
sw.Do(" }\n", nil)
sw.Do("}()", nil)
}
}
for _, comment := range v.Comments {
sw.Do("// $.$\n", comment)
}
if isShortCircuit {
sw.Do("if e := ", nil)
emitCall()
sw.Do("; len(e) != 0 {\n", nil)
if !isNonError {
sw.Do("errs = append(errs, e...)\n", nil)
}
sw.Do(" return // do not proceed\n", nil)
sw.Do("}\n", nil)
} else {
if isNonError {
emitCall()
} else {
sw.Do("errs = append(errs, ", nil)
emitCall()
sw.Do("...)\n", nil)
}
}
sorted := sooner
sorted = append(sorted, later...)
result = append(result, sorted)
}
return result
}
func emitComments(comments []string, sw *generator.SnippetWriter) {

View File

@@ -17,8 +17,10 @@ limitations under the License.
package main
import (
"reflect"
"testing"
"k8s.io/code-generator/cmd/validation-gen/validators"
"k8s.io/gengo/v2/types"
)
@@ -366,3 +368,111 @@ func TestGetLeafTypeAndPrefixes(t *testing.T) {
}
}
}
func TestSortIntoCohorts(t *testing.T) {
cases := []struct {
in []validators.FunctionGen
expected [][]validators.FunctionGen
}{{
// empty
in: []validators.FunctionGen{},
expected: [][]validators.FunctionGen{},
}, {
// default cohort
in: []validators.FunctionGen{
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
},
expected: [][]validators.FunctionGen{{
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
}},
}, {
// default cohort, not already sorted by name
in: []validators.FunctionGen{
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
},
expected: [][]validators.FunctionGen{{
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
}},
}, {
// default cohort, with a short-circuit
in: []validators.FunctionGen{
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
},
expected: [][]validators.FunctionGen{{
{TagName: "b", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
}},
}, {
// default cohort, with 2 short-circuits
in: []validators.FunctionGen{
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "d", Cohort: "", Flags: validators.ShortCircuit},
},
expected: [][]validators.FunctionGen{{
{TagName: "b", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "d", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "a", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
}},
}, {
// default and non-default cohorts
in: []validators.FunctionGen{
{TagName: "a", Cohort: "foo", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "bar", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "d", Cohort: "foo", Flags: validators.DefaultFlags},
{TagName: "e", Cohort: "bar", Flags: validators.DefaultFlags},
{TagName: "f", Cohort: "", Flags: validators.DefaultFlags},
},
expected: [][]validators.FunctionGen{{
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "f", Cohort: "", Flags: validators.DefaultFlags},
}, {
{TagName: "a", Cohort: "foo", Flags: validators.DefaultFlags},
{TagName: "d", Cohort: "foo", Flags: validators.DefaultFlags},
}, {
{TagName: "b", Cohort: "bar", Flags: validators.DefaultFlags},
{TagName: "e", Cohort: "bar", Flags: validators.DefaultFlags},
}},
}, {
// default and non-default cohorts with short-circuit
in: []validators.FunctionGen{
{TagName: "a", Cohort: "foo", Flags: validators.DefaultFlags},
{TagName: "b", Cohort: "bar", Flags: validators.DefaultFlags},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
{TagName: "d", Cohort: "foo", Flags: validators.ShortCircuit},
{TagName: "e", Cohort: "bar", Flags: validators.ShortCircuit},
{TagName: "f", Cohort: "", Flags: validators.ShortCircuit},
},
expected: [][]validators.FunctionGen{{
{TagName: "f", Cohort: "", Flags: validators.ShortCircuit},
{TagName: "c", Cohort: "", Flags: validators.DefaultFlags},
}, {
{TagName: "d", Cohort: "foo", Flags: validators.ShortCircuit},
{TagName: "a", Cohort: "foo", Flags: validators.DefaultFlags},
}, {
{TagName: "e", Cohort: "bar", Flags: validators.ShortCircuit},
{TagName: "b", Cohort: "bar", Flags: validators.DefaultFlags},
}},
}}
for _, tc := range cases {
out := sortIntoCohorts(tc.in)
if !reflect.DeepEqual(out, tc.expected) {
t.Errorf("expected %v, got %v", tc.expected, out)
}
}
}

View File

@@ -73,7 +73,9 @@ func (frtv fixedResultTagValidator) GetValidations(context Context, tag codetags
if err != nil {
return result, fmt.Errorf("can't decode tag payload: %w", err)
}
result.AddFunction(Function(frtv.TagName(), args.flags, fixedResultValidator, frtv.result, args.msg).WithTypeArgs(args.typeArgs...))
fn := Function(frtv.TagName(), args.flags, fixedResultValidator, frtv.result, args.msg).WithTypeArgs(args.typeArgs...)
fn.Cohort = args.cohort
result.AddFunction(fn)
return result, nil
}
@@ -86,6 +88,7 @@ type fixedResultArgs struct {
flags FunctionFlags
msg string
typeArgs []types.Name
cohort string
}
func (fixedResultTagValidator) toFixedResultArgs(in codetags.Tag) (fixedResultArgs, error) {
@@ -111,6 +114,8 @@ func (fixedResultTagValidator) toFixedResultArgs(in codetags.Tag) (fixedResultAr
}
result.typeArgs = []types.Name{{Package: "", Name: tn}}
}
case "cohort":
result.cohort = a.Value
}
}
if in.ValueType == codetags.ValueTypeString {
@@ -149,6 +154,11 @@ func (frtv fixedResultTagValidator) Docs() TagDoc {
Description: "<string>",
Docs: "The type arg in generated code (must be the value-type, not pointer).",
Type: codetags.ArgTypeString,
}, {
Name: "cohort",
Description: "<string>",
Docs: "An optional cohort name to group multiple validations.",
Type: codetags.ArgTypeString,
}}
if frtv.result {
doc.Description = "Always passes validation (useful for testing)."

View File

@@ -445,6 +445,10 @@ type FunctionGen struct {
// TagName is the tag which triggered this function.
TagName string
// Cohort indicates a set of related functions which are processed
// together.
Cohort string
// Flags holds the options for this validator function.
Flags FunctionFlags