Skip to content

Commit 802b122

Browse files
committed
feat: add +k8s:immutable tag with KEP semantics allowing unset -> set
1 parent db964ca commit 802b122

File tree

5 files changed

+307
-81
lines changed

5 files changed

+307
-81
lines changed

staging/src/k8s.io/apimachinery/pkg/api/validate/immutable.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package validate
1818

1919
import (
2020
"context"
21+
"reflect"
2122

2223
"k8s.io/apimachinery/pkg/api/equality"
2324
"k8s.io/apimachinery/pkg/api/operation"
@@ -62,3 +63,162 @@ func FrozenByReflect[T any](_ context.Context, op operation.Operation, fldPath *
6263
}
6364
return nil
6465
}
66+
67+
// ImmutableValueByCompare allows a field to be set
68+
// once then prevents any further changes.
69+
// Semantics:
70+
// - Zero value is considered "unset"
71+
// - Allows ONE transition: unset->set
72+
// - Forbids: modify and clear
73+
// This function is optimized for comparable types.
74+
// For non-comparable types use ImmutableByReflect instead.
75+
func ImmutableValueByCompare[T comparable](ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T) field.ErrorList {
76+
return immutableCheck(op, fldPath, value, oldValue, func(v *T) bool {
77+
if v == nil {
78+
return true
79+
}
80+
var zero T
81+
return *v == zero
82+
})
83+
}
84+
85+
// ImmutablePointerByCompare allows a field to be set
86+
// once then prevents any further changes.
87+
// Semantics:
88+
// - nil is considered "unset"
89+
// - Any non-nil pointer is considered "set" (incl. ptrs to zero values)
90+
// - Allows ONE transition: unset->set (nil -> non-nil)
91+
// - Forbids: modify and clear (non-nil -> nil)
92+
// This function is optimized for comparable types.
93+
// For non-comparable types, use ImmutableByReflect instead.
94+
func ImmutablePointerByCompare[T comparable](ctx context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T) field.ErrorList {
95+
return immutableCheck(op, fldPath, value, oldValue, func(v *T) bool {
96+
return v == nil
97+
})
98+
}
99+
100+
// ImmutableByReflect allows a field to be set
101+
// once then prevents any further changes.
102+
// Semantics:
103+
// - Can be unset at creation
104+
// - Allows ONE transition: set (unset->set)
105+
// - Forbids: modify and clear (set->unset)
106+
// Unlike ImmutableByCompare, this function can be
107+
// used with types that are not directly comparable
108+
// at the cost of performance.
109+
func ImmutableByReflect[T any](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue T) field.ErrorList {
110+
if op.Type != operation.Update {
111+
return nil
112+
}
113+
114+
valueIsUnset := isUnsetForImmutable(value)
115+
oldValueIsUnset := isUnsetForImmutable(oldValue)
116+
117+
if oldValueIsUnset && valueIsUnset {
118+
return nil
119+
}
120+
if !oldValueIsUnset && !valueIsUnset && equality.Semantic.DeepEqual(value, oldValue) {
121+
return nil
122+
}
123+
124+
switch {
125+
case oldValueIsUnset && !valueIsUnset:
126+
return nil
127+
case !oldValueIsUnset && valueIsUnset:
128+
return field.ErrorList{
129+
field.Forbidden(fldPath, "field is immutable"),
130+
}
131+
case !oldValueIsUnset && !valueIsUnset:
132+
return field.ErrorList{
133+
field.Forbidden(fldPath, "field is immutable"),
134+
}
135+
default:
136+
// Both unset, shouldn't happen since we checked equality
137+
return nil
138+
}
139+
}
140+
141+
func immutableCheck[T comparable](op operation.Operation, fldPath *field.Path,
142+
value, oldValue *T,
143+
isUnset func(*T) bool,
144+
) field.ErrorList {
145+
if op.Type != operation.Update {
146+
return nil
147+
}
148+
149+
if value == nil && oldValue == nil {
150+
return nil
151+
}
152+
if oldValue == nil {
153+
return nil
154+
}
155+
if value == nil {
156+
return field.ErrorList{
157+
field.Forbidden(fldPath, "field is immutable"),
158+
}
159+
}
160+
161+
if *value == *oldValue {
162+
return nil
163+
}
164+
165+
oldIsUnset := isUnset(oldValue)
166+
newIsUnset := isUnset(value)
167+
168+
switch {
169+
case oldIsUnset && !newIsUnset:
170+
return nil
171+
case !oldIsUnset && newIsUnset:
172+
return field.ErrorList{
173+
field.Forbidden(fldPath, "field is immutable"),
174+
}
175+
case !oldIsUnset && !newIsUnset:
176+
return field.ErrorList{
177+
field.Forbidden(fldPath, "field is immutable"),
178+
}
179+
default:
180+
// Both unset, shouldn't happen since we checked equality
181+
return nil
182+
}
183+
}
184+
185+
// isUnsetForImmutable determines if a value should
186+
// be considered "unset" for immutability.
187+
func isUnsetForImmutable(value interface{}) bool {
188+
if value == nil {
189+
return true
190+
}
191+
192+
v := reflect.ValueOf(value)
193+
194+
if v.Kind() == reflect.Ptr {
195+
if v.IsNil() {
196+
return true
197+
}
198+
elem := v.Elem()
199+
200+
// If this is a pointer to a struct, check if the struct is zero
201+
if elem.Kind() == reflect.Struct {
202+
zero := reflect.Zero(elem.Type())
203+
return reflect.DeepEqual(elem.Interface(), zero.Interface())
204+
}
205+
206+
// For pointers to other types, being non-nil means it's set.
207+
// Aligns with +k8s:required behavior for pointer fields.
208+
return false
209+
}
210+
211+
switch v.Kind() {
212+
case reflect.Slice, reflect.Map:
213+
return v.IsNil() || v.Len() == 0
214+
case reflect.String:
215+
return v.String() == ""
216+
case reflect.Struct:
217+
return reflect.DeepEqual(v.Interface(), reflect.Zero(v.Type()).Interface())
218+
case reflect.Interface:
219+
return v.IsNil()
220+
default:
221+
// For other types check if it's the zero value.
222+
return v.Interface() == reflect.Zero(v.Type()).Interface()
223+
}
224+
}

staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/frozen/zz_generated.validations.go

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

staging/src/k8s.io/code-generator/cmd/validation-gen/validators/common.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ limitations under the License.
1717
package validators
1818

1919
import (
20+
"encoding/json"
21+
"fmt"
22+
"reflect"
23+
24+
"k8s.io/code-generator/cmd/validation-gen/util"
25+
"k8s.io/gengo/v2"
2026
"k8s.io/gengo/v2/types"
2127
)
2228

@@ -34,3 +40,68 @@ func rootTypeString(src, dst *types.Type) string {
3440
}
3541
return src.String() + " -> " + dst.String()
3642
}
43+
44+
// hasZeroDefault returns whether the field has a default value and whether
45+
// that default value is the zero value for the field's type.
46+
func hasZeroDefault(context Context) (bool, bool, error) {
47+
t := util.NonPointer(util.NativeType(context.Type))
48+
// This validator only applies to fields, so Member must be valid.
49+
tagsByName, err := gengo.ExtractFunctionStyleCommentTags("+", []string{defaultTagName}, context.Member.CommentLines)
50+
if err != nil {
51+
return false, false, fmt.Errorf("failed to read tags: %w", err)
52+
}
53+
54+
tags, hasDefault := tagsByName[defaultTagName]
55+
if !hasDefault {
56+
return false, false, nil
57+
}
58+
if len(tags) == 0 {
59+
return false, false, fmt.Errorf("+default tag with no value")
60+
}
61+
if len(tags) > 1 {
62+
return false, false, fmt.Errorf("+default tag with multiple values: %q", tags)
63+
}
64+
65+
payload := tags[0].Value
66+
var defaultValue any
67+
if err := json.Unmarshal([]byte(payload), &defaultValue); err != nil {
68+
return false, false, fmt.Errorf("failed to parse default value %q: %w", payload, err)
69+
}
70+
if defaultValue == nil {
71+
return false, false, fmt.Errorf("failed to parse default value %q: unmarshalled to nil", payload)
72+
}
73+
74+
zero, found := typeZeroValue[t.String()]
75+
if !found {
76+
return false, false, fmt.Errorf("unknown zero-value for type %s", t.String())
77+
}
78+
79+
return true, reflect.DeepEqual(defaultValue, zero), nil
80+
}
81+
82+
// This is copied from defaulter-gen.
83+
// TODO: move this back to gengo as Type.ZeroValue()?
84+
var typeZeroValue = map[string]any{
85+
"uint": 0.,
86+
"uint8": 0.,
87+
"uint16": 0.,
88+
"uint32": 0.,
89+
"uint64": 0.,
90+
"int": 0.,
91+
"int8": 0.,
92+
"int16": 0.,
93+
"int32": 0.,
94+
"int64": 0.,
95+
"byte": 0.,
96+
"float64": 0.,
97+
"float32": 0.,
98+
"bool": false,
99+
"time.Time": "",
100+
"string": "",
101+
"integer": 0.,
102+
"number": 0.,
103+
"boolean": false,
104+
"[]byte": "", // base64 encoded characters
105+
"interface{}": interface{}(nil),
106+
"any": interface{}(nil),
107+
}

staging/src/k8s.io/code-generator/cmd/validation-gen/validators/immutable.go

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import (
2424
)
2525

2626
const (
27-
frozenTagName = "k8s:frozen"
27+
frozenTagName = "k8s:frozen"
28+
immutableTagName = "k8s:immutable"
2829
)
2930

3031
func init() {
3132
RegisterTagValidator(frozenTagValidator{})
33+
RegisterTagValidator(immutableTagValidator{})
3234
}
3335

3436
type frozenTagValidator struct{}
@@ -54,11 +56,6 @@ func (frozenTagValidator) GetValidations(context Context, _ codetags.Tag) (Valid
5456
var result Validations
5557

5658
if util.IsDirectComparable(util.NonPointer(util.NativeType(context.Type))) {
57-
// This is a minor optimization to just compare primitive values when
58-
// possible. Slices and maps are not comparable, and structs might hold
59-
// pointer fields, which are directly comparable but not what we need.
60-
//
61-
// Note: This compares the pointee, not the pointer itself.
6259
result.AddFunction(Function(frozenTagName, DefaultFlags, frozenCompareValidator))
6360
} else {
6461
result.AddFunction(Function(frozenTagName, DefaultFlags, frozenReflectValidator))
@@ -74,3 +71,69 @@ func (ftv frozenTagValidator) Docs() TagDoc {
7471
Description: "Indicates that a field may not be updated.",
7572
}
7673
}
74+
75+
type immutableTagValidator struct{}
76+
77+
func (immutableTagValidator) Init(_ Config) {}
78+
79+
func (immutableTagValidator) TagName() string {
80+
return immutableTagName
81+
}
82+
83+
var immutableTagValidScopes = sets.New(ScopeField, ScopeType, ScopeMapVal, ScopeListVal)
84+
85+
func (immutableTagValidator) ValidScopes() sets.Set[Scope] {
86+
return immutableTagValidScopes
87+
}
88+
89+
var (
90+
immutableValueByCompareValidator = types.Name{Package: libValidationPkg, Name: "ImmutableValueByCompare"}
91+
immutablePointerByCompareValidator = types.Name{Package: libValidationPkg, Name: "ImmutablePointerByCompare"}
92+
immutableReflectValidator = types.Name{Package: libValidationPkg, Name: "ImmutableByReflect"}
93+
)
94+
95+
func (itv immutableTagValidator) GetValidations(context Context, _ codetags.Tag) (Validations, error) {
96+
var result Validations
97+
98+
// If validating a field, check for default value.
99+
if context.Member != nil {
100+
if hasDefault, zeroDefault, err := hasZeroDefault(context); err != nil {
101+
return Validations{}, err
102+
} else if hasDefault && zeroDefault {
103+
result.AddComment("Zero-value defaults are treated as 'unset' by immutable validation.")
104+
} else if hasDefault && !zeroDefault {
105+
result.AddComment("Non-zero defaults are 'always set' and cannot transition from unset to set.")
106+
}
107+
}
108+
109+
if !util.IsDirectComparable(util.NonPointer(util.NativeType(context.Type))) {
110+
result.AddFunction(Function(immutableTagName, DefaultFlags, immutableReflectValidator))
111+
return result, nil
112+
}
113+
114+
isPointerField := false
115+
if context.Member != nil {
116+
memberType := context.Member.Type
117+
if memberType != nil && memberType.Kind == types.Pointer {
118+
isPointerField = true
119+
}
120+
} else if util.NativeType(context.Type).Kind == types.Pointer {
121+
isPointerField = true
122+
}
123+
124+
if isPointerField {
125+
result.AddFunction(Function(immutableTagName, DefaultFlags, immutablePointerByCompareValidator))
126+
} else {
127+
result.AddFunction(Function(immutableTagName, DefaultFlags, immutableValueByCompareValidator))
128+
}
129+
130+
return result, nil
131+
}
132+
133+
func (itv immutableTagValidator) Docs() TagDoc {
134+
return TagDoc{
135+
Tag: itv.TagName(),
136+
Scopes: itv.ValidScopes().UnsortedList(),
137+
Description: "Indicates that a field can be set once (now or at creation), then becomes immutable. Allows transition from unset to set, but forbids modify or clear operations. Fields with default values are considered already set.",
138+
}
139+
}

0 commit comments

Comments
 (0)