Handle optional value-types with defaults

This commit is contained in:
Tim Hockin 2025-02-22 18:04:33 -08:00 committed by Joe Betz
parent 141e98ed05
commit 92aeb63a5b
8 changed files with 543 additions and 5 deletions

View File

@ -0,0 +1,53 @@
/*
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
// This is a test package.
package nonzerodefaults
import "k8s.io/code-generator/cmd/validation-gen/testscheme"
var localSchemeBuilder = testscheme.New()
type Struct struct {
TypeMeta int
// +k8s:optional
// +default="foobar"
StringField string `json:"stringField"`
// +k8s:optional
// +default="foobar"
StringPtrField *string `json:"stringPtrField"`
// +k8s:optional
// +default=123
IntField int `json:"intField"`
// +k8s:optional
// +default=123
IntPtrField *int `json:"intPtrField"`
// +k8s:optional
// +default=true
BoolField bool `json:"boolField"`
// +k8s:optional
// +default=true
BoolPtrField *bool `json:"boolPtrField"`
}

View File

@ -0,0 +1,47 @@
/*
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 nonzerodefaults
import (
"testing"
"k8s.io/utils/ptr"
)
func Test(t *testing.T) {
st := localSchemeBuilder.Test(t)
st.Value(&Struct{
// All zero-values.
}).ExpectRegexpsByPath(map[string][]string{
"stringField": {"Required value"},
"stringPtrField": {"Required value"},
"intField": {"Required value"},
"intPtrField": {"Required value"},
"boolField": {"Required value"},
"boolPtrField": {"Required value"},
})
st.Value(&Struct{
StringField: "abc",
StringPtrField: ptr.To(""),
IntField: 123,
IntPtrField: ptr.To(0),
BoolField: true,
BoolPtrField: ptr.To(false),
}).ExpectValid()
}

View File

@ -0,0 +1,119 @@
//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 nonzerodefaults
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 {
scheme.AddValidationFunc((*Struct)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}, subresources ...string) field.ErrorList {
if len(subresources) == 0 {
return Validate_Struct(ctx, op, nil /* fldPath */, obj.(*Struct), safe.Cast[*Struct](oldObj))
}
return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresources: %v", obj, subresources))}
})
return nil
}
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.StringField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredValue(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("stringField"), &obj.StringField, safe.Field(oldObj, func(oldObj *Struct) *string { return &oldObj.StringField }))...)
// field Struct.StringPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("stringPtrField"), obj.StringPtrField, safe.Field(oldObj, func(oldObj *Struct) *string { return oldObj.StringPtrField }))...)
// field Struct.IntField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *int) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredValue(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("intField"), &obj.IntField, safe.Field(oldObj, func(oldObj *Struct) *int { return &oldObj.IntField }))...)
// field Struct.IntPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *int) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("intPtrField"), obj.IntPtrField, safe.Field(oldObj, func(oldObj *Struct) *int { return oldObj.IntPtrField }))...)
// field Struct.BoolField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *bool) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredValue(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("boolField"), &obj.BoolField, safe.Field(oldObj, func(oldObj *Struct) *bool { return &oldObj.BoolField }))...)
// field Struct.BoolPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *bool) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("boolPtrField"), obj.BoolPtrField, safe.Field(oldObj, func(oldObj *Struct) *bool { return oldObj.BoolPtrField }))...)
return errs
}

View File

@ -0,0 +1,53 @@
/*
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
// This is a test package.
package zerodefaults
import "k8s.io/code-generator/cmd/validation-gen/testscheme"
var localSchemeBuilder = testscheme.New()
type Struct struct {
TypeMeta int
// +k8s:optional
// +default=""
StringField string `json:"stringField"`
// +k8s:optional
// +default=""
StringPtrField *string `json:"stringPtrField"`
// +k8s:optional
// +default=0
IntField int `json:"intField"`
// +k8s:optional
// +default=0
IntPtrField *int `json:"intPtrField"`
// +k8s:optional
// +default=false
BoolField bool `json:"boolField"`
// +k8s:optional
// +default=false
BoolPtrField *bool `json:"boolPtrField"`
}

View File

@ -0,0 +1,47 @@
/*
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 zerodefaults
import (
"testing"
"k8s.io/utils/ptr"
)
func Test(t *testing.T) {
st := localSchemeBuilder.Test(t)
st.Value(&Struct{
// All zero-values.
}).ExpectRegexpsByPath(map[string][]string{
// "stringField": optional value fields with zero defaults are just docs
// "intField": optional value fields with zero defaults are just docs
// "boolField": optional value fields with zero defaults are just docs
"stringPtrField": {"Required value"},
"intPtrField": {"Required value"},
"boolPtrField": {"Required value"},
})
st.Value(&Struct{
StringField: "abc",
StringPtrField: ptr.To(""),
IntField: 123,
IntPtrField: ptr.To(0),
BoolField: true,
BoolPtrField: ptr.To(false),
}).ExpectValid()
}

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 zerodefaults
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 {
scheme.AddValidationFunc((*Struct)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}, subresources ...string) field.ErrorList {
if len(subresources) == 0 {
return Validate_Struct(ctx, op, nil /* fldPath */, obj.(*Struct), safe.Cast[*Struct](oldObj))
}
return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresources: %v", obj, subresources))}
})
return nil
}
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.StringField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// optional value-type fields with zero-value defaults are purely documentation
return
}(fldPath.Child("stringField"), &obj.StringField, safe.Field(oldObj, func(oldObj *Struct) *string { return &oldObj.StringField }))...)
// field Struct.StringPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *string) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("stringPtrField"), obj.StringPtrField, safe.Field(oldObj, func(oldObj *Struct) *string { return oldObj.StringPtrField }))...)
// field Struct.IntField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *int) (errs field.ErrorList) {
// optional value-type fields with zero-value defaults are purely documentation
return
}(fldPath.Child("intField"), &obj.IntField, safe.Field(oldObj, func(oldObj *Struct) *int { return &oldObj.IntField }))...)
// field Struct.IntPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *int) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("intPtrField"), obj.IntPtrField, safe.Field(oldObj, func(oldObj *Struct) *int { return oldObj.IntPtrField }))...)
// field Struct.BoolField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *bool) (errs field.ErrorList) {
// optional value-type fields with zero-value defaults are purely documentation
return
}(fldPath.Child("boolField"), &obj.BoolField, safe.Field(oldObj, func(oldObj *Struct) *bool { return &oldObj.BoolField }))...)
// field Struct.BoolPtrField
errs = append(errs,
func(fldPath *field.Path, obj, oldObj *bool) (errs field.ErrorList) {
// optional fields with default values are effectively required
if e := validate.RequiredPointer(ctx, op, fldPath, obj, oldObj); len(e) != 0 {
errs = append(errs, e...)
return // do not proceed
}
return
}(fldPath.Child("boolPtrField"), obj.BoolPtrField, safe.Field(oldObj, func(oldObj *Struct) *bool { return oldObj.BoolPtrField }))...)
return errs
}

View File

@ -17,16 +17,21 @@ limitations under the License.
package validators
import (
"encoding/json"
"fmt"
"reflect"
"k8s.io/gengo/v2"
"k8s.io/gengo/v2/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/gengo/v2/types"
)
const (
requiredTagName = "k8s:required"
optionalTagName = "k8s:optional"
forbiddenTagName = "k8s:forbidden"
defaultTagName = "default" // TODO: this should evenually be +k8s:default
)
func init() {
@ -84,7 +89,7 @@ var (
// TODO: It might be valuable to have a string payload for when requiredness is
// conditional (e.g. required when <otherfield> is specified).
func (requirednessTagValidator) doRequired(context Context) (Validations, error) {
func (rtv requirednessTagValidator) doRequired(context Context) (Validations, error) {
// Most validators don't care whether the value they are validating was
// originally defined as a value-type or a pointer-type in the API. This
// one does. Since Go doesn't do partial specialization of templates, we
@ -114,7 +119,48 @@ var (
optionalMapValidator = types.Name{Package: libValidationPkg, Name: "OptionalMap"}
)
func (requirednessTagValidator) doOptional(context Context) (Validations, error) {
func (rtv requirednessTagValidator) doOptional(context Context) (Validations, error) {
// All of our tags are expressed from the perspective of a client of the
// API, but the code we generate is for the server. Optional is tricky.
//
// A field which is marked as optional and does not have a default is
// strictly optional. A client is allowed to not set it and the server will
// not give it a default value. Code which consumes it must handle that it
// might not have any value at all.
//
// A field which is marked as optional but has a default is optional to
// clients, but required to the server. A client is allowed to not set it
// but the server will give it a default value. Code which consumes it can
// assume that it always has a value.
//
// One special case must be handled: optional non-pointer fields with
// default values. If the default is not the zero value for the type, then
// the zero value is used to decide whether to assign the default value,
// and so must be out of bounds; we can proceed as above.
//
// But if the default is the zero value, then the zero value is obviously
// valid, and the fact that the field is optional is meaningless - there is
// no way to tell the difference between a client not setting it (yielding
// the zero value) and a client setting it to the zero value.
//
// TODO: handle default=ref(...)
// TODO: handle manual defaulting
if hasDefault, zeroDefault, err := rtv.hasZeroDefault(context); err != nil {
return Validations{}, err
} else if hasDefault {
if !isNilableType(context.Type) && zeroDefault {
return Validations{Comments: []string{"optional value-type fields with zero-value defaults are purely documentation"}}, nil
}
validations, err := rtv.doRequired(context)
if err != nil {
return Validations{}, err
}
for i, fn := range validations.Functions {
validations.Functions[i] = WithComment(fn, "optional fields with default values are effectively required")
}
return validations, nil
}
// Most validators don't care whether the value they are validating was
// originally defined as a value-type or a pointer-type in the API. This
// one does. Since Go doesn't do partial specialization of templates, we
@ -137,6 +183,71 @@ func (requirednessTagValidator) doOptional(context Context) (Validations, error)
return Validations{Functions: []FunctionGen{Function(optionalTagName, ShortCircuit|NonError, optionalValueValidator)}}, nil
}
// hasZeroDefault returns whether the field has a default value and whether
// that default value is the zero value for the field's type.
func (rtv requirednessTagValidator) hasZeroDefault(context Context) (bool, bool, error) {
t := realType(context.Type)
// This validator only applies to fields, so Member must be valid.
tagsByName, err := gengo.ExtractFunctionStyleCommentTags("+", []string{defaultTagName}, context.Member.CommentLines)
if err != nil {
return false, false, fmt.Errorf("failed to read tags: %w", err)
}
tags, hasDefault := tagsByName[defaultTagName]
if !hasDefault {
return false, false, nil
}
if len(tags) == 0 {
return false, false, fmt.Errorf("+default tag with no value")
}
if len(tags) > 1 {
return false, false, fmt.Errorf("+default tag with multiple values: %q", tags)
}
payload := tags[0].Value
var defaultValue any
if err := json.Unmarshal([]byte(payload), &defaultValue); err != nil {
return false, false, fmt.Errorf("failed to parse default value %q: %w", payload, err)
}
if defaultValue == nil {
return false, false, fmt.Errorf("failed to parse default value %q: unmarshalled to nil", payload)
}
zero, found := typeZeroValue[t.String()]
if !found {
return false, false, fmt.Errorf("unknown zero-value for type %s", t.String())
}
return true, reflect.DeepEqual(defaultValue, zero), nil
}
// This is copied from defaulter-gen.
// TODO: move this back to gengo as Type.ZeroValue()?
var typeZeroValue = map[string]any{
"uint": 0.,
"uint8": 0.,
"uint16": 0.,
"uint32": 0.,
"uint64": 0.,
"int": 0.,
"int8": 0.,
"int16": 0.,
"int32": 0.,
"int64": 0.,
"byte": 0.,
"float64": 0.,
"float32": 0.,
"bool": false,
"time.Time": "",
"string": "",
"integer": 0.,
"number": 0.,
"boolean": false,
"[]byte": "", // base64 encoded characters
"interface{}": interface{}(nil),
"any": interface{}(nil),
}
var (
forbiddenValueValidator = types.Name{Package: libValidationPkg, Name: "ForbiddenValue"}
forbiddenPointerValidator = types.Name{Package: libValidationPkg, Name: "ForbiddenPointer"}

View File

@ -17,10 +17,11 @@ limitations under the License.
package validators
import (
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/gengo/v2/generator"
"k8s.io/gengo/v2/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
)
// TagValidator describes a single validation tag and how to use it.