diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/update.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/update.go new file mode 100644 index 00000000000..e67ee28ded3 --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/update.go @@ -0,0 +1,160 @@ +/* +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/equality" + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// UpdateConstraint represents a constraint on update operations +type UpdateConstraint int + +const ( + // NoSet prevents unset->set transitions + NoSet UpdateConstraint = iota + // NoUnset prevents set->unset transitions + NoUnset + // NoModify prevents value changes but allows set/unset transitions + NoModify +) + +// UpdateValueByCompare verifies update constraints for comparable value types. +func UpdateValueByCompare[T comparable](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T, constraints ...UpdateConstraint) field.ErrorList { + if op.Type != operation.Update { + return nil + } + + var errs field.ErrorList + var zero T + + for _, constraint := range constraints { + switch constraint { + case NoSet: + if *oldValue == zero && *value != zero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be set once created").WithOrigin("update")) + } + case NoUnset: + if *oldValue != zero && *value == zero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be cleared once set").WithOrigin("update")) + } + case NoModify: + // Rely on validation ratcheting to detect that the value has changed. + // This check only verifies that the field was set in both the old and + // new objects, confirming it was a modification, not a set/unset. + if *oldValue != zero && *value != zero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be modified once set").WithOrigin("update")) + } + } + } + + return errs +} + +// UpdatePointer verifies update constraints for pointer types. +func UpdatePointer[T any](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T, constraints ...UpdateConstraint) field.ErrorList { + if op.Type != operation.Update { + return nil + } + + var errs field.ErrorList + + for _, constraint := range constraints { + switch constraint { + case NoSet: + if oldValue == nil && value != nil { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be set once created").WithOrigin("update")) + } + case NoUnset: + if oldValue != nil && value == nil { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be cleared once set").WithOrigin("update")) + } + case NoModify: + // Rely on validation ratcheting to detect that the value has changed. + // This check only verifies that the field was non-nil in both the old + // and new objects, confirming it was a modification, not a set/unset. + if oldValue != nil && value != nil { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be modified once set").WithOrigin("update")) + } + } + } + + return errs +} + +// UpdateValueByReflect verifies update constraints for non-comparable value types using reflection. +func UpdateValueByReflect[T any](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T, constraints ...UpdateConstraint) field.ErrorList { + if op.Type != operation.Update { + return nil + } + + var errs field.ErrorList + var zero T + valueIsZero := equality.Semantic.DeepEqual(*value, zero) + oldValueIsZero := equality.Semantic.DeepEqual(*oldValue, zero) + + for _, constraint := range constraints { + switch constraint { + case NoSet: + if oldValueIsZero && !valueIsZero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be set once created").WithOrigin("update")) + } + case NoUnset: + if !oldValueIsZero && valueIsZero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be cleared once set").WithOrigin("update")) + } + case NoModify: + // Rely on validation ratcheting to detect that the value has changed. + // This check only verifies that the field was set in both the old and + // new objects, confirming it was a modification, not a set/unset. + if !oldValueIsZero && !valueIsZero { + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be modified once set").WithOrigin("update")) + } + } + } + + return errs +} + +// UpdateStruct verifies update constraints for non-pointer struct types. +// Non-pointer structs are always considered "set" and never "unset". +func UpdateStruct[T any](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T, constraints ...UpdateConstraint) field.ErrorList { + if op.Type != operation.Update { + return nil + } + + var errs field.ErrorList + + for _, constraint := range constraints { + switch constraint { + case NoSet, NoUnset: + // These constraints don't apply to non-pointer structs + // as they can't be unset. This should be caught at generation time. + continue + case NoModify: + // Non-pointer structs are always considered "set". Therefore, any + // change detected by validation ratcheting is a modification. + // The deep equality check is redundant and has been removed. + errs = append(errs, field.Invalid(fldPath, nil, "field cannot be modified once set").WithOrigin("update")) + } + } + + return errs +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/update_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/update_test.go new file mode 100644 index 00000000000..369da702c80 --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/update_test.go @@ -0,0 +1,430 @@ +/* +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" + "testing" + + "k8s.io/apimachinery/pkg/api/operation" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestUpdateValue(t *testing.T) { + tests := []struct { + name string + op operation.Type + value string + oldValue string + constraints []UpdateConstraint + wantErrs int + wantMsgs []string + }{ + { + name: "create operation - no validation", + op: operation.Create, + value: "value", + oldValue: "", + constraints: []UpdateConstraint{NoSet, NoUnset, NoModify}, + wantErrs: 0, + }, + { + name: "NoSet - unset to set transition (forbidden)", + op: operation.Update, + value: "value", + oldValue: "", + constraints: []UpdateConstraint{NoSet}, + wantErrs: 1, + wantMsgs: []string{"field cannot be set once created"}, + }, + { + name: "NoSet - set to set transition (allowed)", + op: operation.Update, + value: "value2", + oldValue: "value1", + constraints: []UpdateConstraint{NoSet}, + wantErrs: 0, + }, + { + name: "NoUnset - set to unset transition (forbidden)", + op: operation.Update, + value: "", + oldValue: "value", + constraints: []UpdateConstraint{NoUnset}, + wantErrs: 1, + wantMsgs: []string{"field cannot be cleared once set"}, + }, + { + name: "NoUnset - unset to set transition (allowed)", + op: operation.Update, + value: "value", + oldValue: "", + constraints: []UpdateConstraint{NoUnset}, + wantErrs: 0, + }, + { + name: "NoModify - set to different value (forbidden)", + op: operation.Update, + value: "value2", + oldValue: "value1", + constraints: []UpdateConstraint{NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "NoModify - unset to set transition (allowed)", + op: operation.Update, + value: "value", + oldValue: "", + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "NoModify - set to unset transition (allowed)", + op: operation.Update, + value: "", + oldValue: "value", + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "Multiple constraints - NoSet and NoUnset", + op: operation.Update, + value: "value", + oldValue: "", + constraints: []UpdateConstraint{NoSet, NoUnset}, + wantErrs: 1, + wantMsgs: []string{"field cannot be set once created"}, + }, + { + name: "Multiple constraints - NoUnset and NoModify", + op: operation.Update, + value: "", + oldValue: "value", + constraints: []UpdateConstraint{NoUnset, NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be cleared once set"}, + }, + { + name: "Multiple constraints - NoSet, NoUnset, NoModify - modify attempt", + op: operation.Update, + value: "value2", + oldValue: "value1", + constraints: []UpdateConstraint{NoSet, NoUnset, NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "No constraints", + op: operation.Update, + value: "value2", + oldValue: "value1", + constraints: []UpdateConstraint{}, + wantErrs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: tt.op} + errs := UpdateValueByCompare(context.TODO(), op, field.NewPath("test"), &tt.value, &tt.oldValue, tt.constraints...) + if len(errs) != tt.wantErrs { + t.Errorf("UpdateValue() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + for i, msg := range tt.wantMsgs { + if i >= len(errs) { + t.Errorf("Expected error message %q not found", msg) + continue + } + if errs[i].Detail != msg { + t.Errorf("UpdateValue() error message = %q, want %q", errs[i].Detail, msg) + } + } + }) + } +} + +func TestUpdatePointer(t *testing.T) { + stringPtr := func(s string) *string { return &s } + + tests := []struct { + name string + op operation.Type + value *string + oldValue *string + constraints []UpdateConstraint + wantErrs int + wantMsgs []string + }{ + { + name: "create operation - no validation", + op: operation.Create, + value: stringPtr("value"), + oldValue: nil, + constraints: []UpdateConstraint{NoSet, NoUnset, NoModify}, + wantErrs: 0, + }, + { + name: "NoSet - nil to non-nil transition (forbidden)", + op: operation.Update, + value: stringPtr("value"), + oldValue: nil, + constraints: []UpdateConstraint{NoSet}, + wantErrs: 1, + wantMsgs: []string{"field cannot be set once created"}, + }, + { + name: "NoSet - non-nil to non-nil transition (allowed)", + op: operation.Update, + value: stringPtr("value2"), + oldValue: stringPtr("value1"), + constraints: []UpdateConstraint{NoSet}, + wantErrs: 0, + }, + { + name: "NoUnset - non-nil to nil transition (forbidden)", + op: operation.Update, + value: nil, + oldValue: stringPtr("value"), + constraints: []UpdateConstraint{NoUnset}, + wantErrs: 1, + wantMsgs: []string{"field cannot be cleared once set"}, + }, + { + name: "NoUnset - nil to non-nil transition (allowed)", + op: operation.Update, + value: stringPtr("value"), + oldValue: nil, + constraints: []UpdateConstraint{NoUnset}, + wantErrs: 0, + }, + { + name: "NoModify - different values (forbidden)", + op: operation.Update, + value: stringPtr("value2"), + oldValue: stringPtr("value1"), + constraints: []UpdateConstraint{NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "NoModify - nil to non-nil transition (allowed)", + op: operation.Update, + value: stringPtr("value"), + oldValue: nil, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "NoModify - non-nil to nil transition (allowed)", + op: operation.Update, + value: nil, + oldValue: stringPtr("value"), + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "Multiple constraints - all three", + op: operation.Update, + value: stringPtr("value2"), + oldValue: stringPtr("value1"), + constraints: []UpdateConstraint{NoSet, NoUnset, NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: tt.op} + errs := UpdatePointer(context.TODO(), op, field.NewPath("test"), tt.value, tt.oldValue, tt.constraints...) + if len(errs) != tt.wantErrs { + t.Errorf("UpdatePointer() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + for i, msg := range tt.wantMsgs { + if i >= len(errs) { + t.Errorf("Expected error message %q not found", msg) + continue + } + if errs[i].Detail != msg { + t.Errorf("UpdatePointer() error message = %q, want %q", errs[i].Detail, msg) + } + } + }) + } +} + +func TestUpdateValueByReflect(t *testing.T) { + type CustomStruct struct { + Field1 string + Field2 int + } + + tests := []struct { + name string + op operation.Type + value CustomStruct + oldValue CustomStruct + constraints []UpdateConstraint + wantErrs int + wantMsgs []string + }{ + { + name: "NoModify - zero to non-zero transition (allowed)", + op: operation.Update, + value: CustomStruct{Field1: "test", Field2: 42}, + oldValue: CustomStruct{}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "NoModify - non-zero to zero transition (allowed)", + op: operation.Update, + value: CustomStruct{}, + oldValue: CustomStruct{Field1: "test", Field2: 42}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "NoModify - different values (forbidden)", + op: operation.Update, + value: CustomStruct{Field1: "test2", Field2: 100}, + oldValue: CustomStruct{Field1: "test1", Field2: 42}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "NoSet - zero to non-zero (forbidden)", + op: operation.Update, + value: CustomStruct{Field1: "test", Field2: 42}, + oldValue: CustomStruct{}, + constraints: []UpdateConstraint{NoSet}, + wantErrs: 1, + wantMsgs: []string{"field cannot be set once created"}, + }, + { + name: "NoUnset - non-zero to zero (forbidden)", + op: operation.Update, + value: CustomStruct{}, + oldValue: CustomStruct{Field1: "test", Field2: 42}, + constraints: []UpdateConstraint{NoUnset}, + wantErrs: 1, + wantMsgs: []string{"field cannot be cleared once set"}, + }, + { + name: "Multiple constraints", + op: operation.Update, + value: CustomStruct{Field1: "test", Field2: 42}, + oldValue: CustomStruct{}, + constraints: []UpdateConstraint{NoSet, NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be set once created"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: tt.op} + errs := UpdateValueByReflect(context.TODO(), op, field.NewPath("test"), &tt.value, &tt.oldValue, tt.constraints...) + if len(errs) != tt.wantErrs { + t.Errorf("UpdateValueByReflect() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + for i, msg := range tt.wantMsgs { + if i >= len(errs) { + t.Errorf("Expected error message %q not found", msg) + continue + } + if errs[i].Detail != msg { + t.Errorf("UpdateValueByReflect() error message = %q, want %q", errs[i].Detail, msg) + } + } + }) + } +} + +func TestUpdateStruct(t *testing.T) { + type TestStruct struct { + Field1 string + Field2 int + } + + tests := []struct { + name string + op operation.Type + value TestStruct + oldValue TestStruct + constraints []UpdateConstraint + wantErrs int + wantMsgs []string + }{ + { + name: "create operation - no validation", + op: operation.Create, + value: TestStruct{Field1: "test", Field2: 42}, + oldValue: TestStruct{}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 0, + }, + { + name: "NoModify - different values (forbidden)", + op: operation.Update, + value: TestStruct{Field1: "test2", Field2: 100}, + oldValue: TestStruct{Field1: "test1", Field2: 42}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "NoModify - zero value to non-zero (forbidden)", + op: operation.Update, + value: TestStruct{Field1: "test", Field2: 42}, + oldValue: TestStruct{}, + constraints: []UpdateConstraint{NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + { + name: "NoSet and NoUnset with modification - only NoModify triggers", + op: operation.Update, + value: TestStruct{Field1: "test2", Field2: 100}, + oldValue: TestStruct{Field1: "test1", Field2: 42}, + constraints: []UpdateConstraint{NoSet, NoUnset, NoModify}, + wantErrs: 1, + wantMsgs: []string{"field cannot be modified once set"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + op := operation.Operation{Type: tt.op} + errs := UpdateStruct(context.TODO(), op, field.NewPath("test"), &tt.value, &tt.oldValue, tt.constraints...) + if len(errs) != tt.wantErrs { + t.Errorf("UpdateStruct() returned %d errors, want %d: %v", len(errs), tt.wantErrs, errs) + } + for i, msg := range tt.wantMsgs { + if i >= len(errs) { + t.Errorf("Expected error message %q not found", msg) + continue + } + if errs[i].Detail != msg { + t.Errorf("UpdateStruct() error message = %q, want %q", errs[i].Detail, msg) + } + } + }) + } +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc.go new file mode 100644 index 00000000000..5c735439107 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc.go @@ -0,0 +1,125 @@ +/* +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 for the +k8s:update tag. +package update + +import "k8s.io/code-generator/cmd/validation-gen/testscheme" + +var localSchemeBuilder = testscheme.New() + +type UpdateTestStruct struct { + TypeMeta int + + // +k8s:update=NoSet + StringNoSet string `json:"stringNoSet"` + + // +k8s:update=NoUnset + StringNoUnset string `json:"stringNoUnset"` + + // +k8s:update=NoModify + StringNoModify string `json:"stringNoModify"` + + // +k8s:update=NoSet + // +k8s:update=NoModify + // +k8s:update=NoUnset + StringFullyRestricted string `json:"stringFullyRestricted"` + + // +k8s:update=NoModify + // +k8s:update=NoUnset + StringSetOnce string `json:"stringSetOnce"` + + // +k8s:update=NoModify + IntNoModify int `json:"intNoModify"` + + // +k8s:update=NoModify + Int32NoModify int32 `json:"int32NoModify"` + + // +k8s:update=NoModify + Int64NoModify int64 `json:"int64NoModify"` + + // +k8s:update=NoModify + UintNoModify uint `json:"uintNoModify"` + + // +k8s:update=NoModify + BoolNoModify bool `json:"boolNoModify"` + + // +k8s:update=NoModify + Float32NoModify float32 `json:"float32NoModify"` + + // +k8s:update=NoModify + Float64NoModify float64 `json:"float64NoModify"` + + // +k8s:update=NoModify + ByteNoModify byte `json:"byteNoModify"` + + // +k8s:update=NoModify + StructNoModify TestStruct `json:"structNoModify"` + + // +k8s:update=NoModify + NonComparableStructNoModify NonComparableStruct `json:"nonComparableStructNoModify"` + + // Pointer field tests + + // +k8s:update=NoSet + PointerNoSet *string `json:"pointerNoSet"` + + // +k8s:update=NoUnset + PointerNoUnset *string `json:"pointerNoUnset"` + + // +k8s:update=NoModify + PointerNoModify *string `json:"pointerNoModify"` + + // +k8s:update=NoSet + // +k8s:update=NoModify + // +k8s:update=NoUnset + PointerFullyRestricted *string `json:"pointerFullyRestricted"` + + // +k8s:update=NoModify + IntPointerNoModify *int `json:"intPointerNoModify"` + + // +k8s:update=NoModify + BoolPointerNoModify *bool `json:"boolPointerNoModify"` + + // +k8s:update=NoModify + StructPointerNoModify *TestStruct `json:"structPointerNoModify"` + + // Type alias tests + + // +k8s:update=NoModify + CustomTypeNoModify CustomString `json:"customTypeNoModify"` + + // +k8s:update=NoSet + CustomTypeNoSet CustomInt `json:"customTypeNoSet"` +} + +type TestStruct struct { + StringField string `json:"stringField"` + IntField int `json:"intField"` +} + +// NonComparableStruct contains a slice which makes it non-comparable +type NonComparableStruct struct { + SliceField []string `json:"sliceField"` + IntField int `json:"intField"` +} + +// Custom types to test type aliases +type CustomString string +type CustomInt int diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc_test.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc_test.go new file mode 100644 index 00000000000..9f6539848b0 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/doc_test.go @@ -0,0 +1,401 @@ +/* +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 update + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" +) + +func TestUpdateTags(t *testing.T) { + st := localSchemeBuilder.Test(t) + + baseStruct := UpdateTestStruct{} + + // String NoSet + old := baseStruct + old.StringNoSet = "" // unset + + new := baseStruct + new.StringNoSet = "value" + + st.Value(&new).OldValue(&old).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringNoSet"), nil, "field cannot be set once created").WithOrigin("update"), + }) + + st.Value(&old).OldValue(&old).ExpectValid() + + // String NoUnset + oldWithValue := baseStruct + oldWithValue.StringNoUnset = "value" + + newUnset := baseStruct + newUnset.StringNoUnset = "" + + // Can set initially (empty to non-empty) + st.Value(&oldWithValue).OldValue(&baseStruct).ExpectValid() + + // Cannot unset (non-empty to empty) + st.Value(&newUnset).OldValue(&oldWithValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringNoUnset"), nil, "field cannot be cleared once set").WithOrigin("update"), + }) + + // String NoModify + oldEmpty := baseStruct + withValue := baseStruct + withValue.StringNoModify = "value" + modified := baseStruct + modified.StringNoModify = "different" + + // Can set initially (empty to non-empty) + st.Value(&withValue).OldValue(&oldEmpty).ExpectValid() + + // Can unset (non-empty to empty) + st.Value(&oldEmpty).OldValue(&withValue).ExpectValid() + + // Cannot modify (non-empty to different non-empty) + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // String Fully Restricted + oldEmpty = baseStruct + withValue = baseStruct + withValue.StringFullyRestricted = "value" + modified = baseStruct + modified.StringFullyRestricted = "different" + + // Cannot set (NoSet) + st.Value(&withValue).OldValue(&oldEmpty).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringFullyRestricted"), nil, "field cannot be set once created").WithOrigin("update"), + }) + + // Cannot unset (NoUnset) + st.Value(&oldEmpty).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringFullyRestricted"), nil, "field cannot be cleared once set").WithOrigin("update"), + }) + + // Cannot modify (NoModify) + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringFullyRestricted"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // String Set-Once Pattern + oldEmpty = baseStruct + withValue = baseStruct + withValue.StringSetOnce = "value" + modified = baseStruct + modified.StringSetOnce = "different" + + // Can set once (empty to non-empty) + st.Value(&withValue).OldValue(&oldEmpty).ExpectValid() + + // Cannot unset (NoUnset) + st.Value(&oldEmpty).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringSetOnce"), nil, "field cannot be cleared once set").WithOrigin("update"), + }) + + // Cannot modify (NoModify) + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("stringSetOnce"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Int NoModify + oldZero := baseStruct + withValue = baseStruct + withValue.IntNoModify = 10 + + // Can transition from 0 to 10 (unset to set is allowed) + st.Value(&withValue).OldValue(&oldZero).ExpectValid() + + // Cannot modify from one non-zero to another + modified = baseStruct + modified.IntNoModify = 20 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("intNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Int32 NoModify + old = baseStruct + withValue = baseStruct + withValue.Int32NoModify = 42 + + // Can set initially + st.Value(&withValue).OldValue(&old).ExpectValid() + + // Cannot modify + modified = baseStruct + modified.Int32NoModify = 100 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("int32NoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Uint NoModify + old = baseStruct + withValue = baseStruct + withValue.UintNoModify = 42 + + // Can set initially + st.Value(&withValue).OldValue(&old).ExpectValid() + + // Cannot modify + modified = baseStruct + modified.UintNoModify = 100 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("uintNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Bool NoModify + oldFalse := baseStruct + withTrue := baseStruct + withTrue.BoolNoModify = true + + // Can transition from false to true (unset to set) + st.Value(&withTrue).OldValue(&oldFalse).ExpectValid() + + // Cannot modify back to false + st.Value(&oldFalse).OldValue(&withTrue).ExpectValid() // This is allowed as it's set->unset + + // Float32 NoModify + old = baseStruct + withValue = baseStruct + withValue.Float32NoModify = 3.14 + + // Can set initially + st.Value(&withValue).OldValue(&old).ExpectValid() + + // Cannot modify + modified = baseStruct + modified.Float32NoModify = 2.71 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("float32NoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Float64 NoModify + oldZero = baseStruct + withValue = baseStruct + withValue.Float64NoModify = 3.14 + + // Can transition from 0.0 to 3.14 + st.Value(&withValue).OldValue(&oldZero).ExpectValid() + + // Cannot modify to different value + modified = baseStruct + modified.Float64NoModify = 2.71 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("float64NoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Byte NoModify + old = baseStruct + withValue = baseStruct + withValue.ByteNoModify = 255 + + // Can set initially + st.Value(&withValue).OldValue(&old).ExpectValid() + + // Cannot modify + modified = baseStruct + modified.ByteNoModify = 128 + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("byteNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Struct NoModify + withStruct := baseStruct + withStruct.StructNoModify = TestStruct{StringField: "value", IntField: 42} + + modifiedStruct := baseStruct + modifiedStruct.StructNoModify = TestStruct{StringField: "different", IntField: 100} + + // Cannot modify (struct fields are always set, never unset) + st.Value(&modifiedStruct).OldValue(&withStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("structNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // NonComparable Struct NoModify - uses reflection + // For non-pointer structs, even zero value is considered "set" + // So any change is a modification + old = baseStruct + old.NonComparableStructNoModify = NonComparableStruct{} // zero value + + withValue = baseStruct + withValue.NonComparableStructNoModify = NonComparableStruct{ + SliceField: []string{"a", "b"}, + IntField: 42, + } + + // Cannot change from zero value to non-zero (both are "set") + st.Value(&withValue).OldValue(&old).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("nonComparableStructNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Also cannot modify between two non-zero values + modified = baseStruct + modified.NonComparableStructNoModify = NonComparableStruct{ + SliceField: []string{"c", "d"}, + IntField: 100, + } + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("nonComparableStructNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Can keep the same value + st.Value(&withValue).OldValue(&withValue).ExpectValid() + + // Pointer NoSet + withSet := baseStruct + withSet.PointerNoSet = ptr.To("value") + + // Cannot set after creation (nil to non-nil) + st.Value(&withSet).OldValue(&baseStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerNoSet"), nil, "field cannot be set once created").WithOrigin("update"), + }) + + // Pointer NoUnset + withPointer := baseStruct + withPointer.PointerNoUnset = ptr.To("value") + + // Can set initially + st.Value(&withPointer).OldValue(&baseStruct).ExpectValid() + + // Cannot unset (non-nil to nil) + st.Value(&baseStruct).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerNoUnset"), nil, "field cannot be cleared once set").WithOrigin("update"), + }) + + // Pointer NoModify + withPointer = baseStruct + withPointer.PointerNoModify = ptr.To("value") + + modifiedPointer := baseStruct + modifiedPointer.PointerNoModify = ptr.To("different") + + // Can set initially + st.Value(&withPointer).OldValue(&baseStruct).ExpectValid() + + // Can unset (NoModify allows set/unset transitions) + st.Value(&baseStruct).OldValue(&withPointer).ExpectValid() + + // Cannot modify content + st.Value(&modifiedPointer).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Pointer Fully Restricted + withPointer = baseStruct + withPointer.PointerFullyRestricted = ptr.To("value") + + modifiedPointer = baseStruct + modifiedPointer.PointerFullyRestricted = ptr.To("different") + + // Cannot set (NoSet) + st.Value(&withPointer).OldValue(&baseStruct).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerFullyRestricted"), nil, "field cannot be set once created").WithOrigin("update"), + }) + + // Cannot unset (NoUnset) + st.Value(&baseStruct).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerFullyRestricted"), nil, "field cannot be cleared once set").WithOrigin("update"), + }) + + // Cannot modify (NoModify) + st.Value(&modifiedPointer).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("pointerFullyRestricted"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Int Pointer NoModify + withPointer = baseStruct + withPointer.IntPointerNoModify = ptr.To(42) + + modifiedPointer = baseStruct + modifiedPointer.IntPointerNoModify = ptr.To(100) + + // Can set initially + st.Value(&withPointer).OldValue(&baseStruct).ExpectValid() + + // Can unset + st.Value(&baseStruct).OldValue(&withPointer).ExpectValid() + + // Cannot modify content + st.Value(&modifiedPointer).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("intPointerNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Bool Pointer NoModify + falseVal := false + trueVal := true + + withFalse := baseStruct + withFalse.BoolPointerNoModify = &falseVal + + withTrue = baseStruct + withTrue.BoolPointerNoModify = &trueVal + + // Can set initially + st.Value(&withFalse).OldValue(&baseStruct).ExpectValid() + + // Cannot modify content + st.Value(&withTrue).OldValue(&withFalse).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("boolPointerNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Struct Pointer NoModify + withPointer = baseStruct + withPointer.StructPointerNoModify = &TestStruct{StringField: "value", IntField: 42} + + modifiedPointer = baseStruct + modifiedPointer.StructPointerNoModify = &TestStruct{StringField: "different", IntField: 100} + + // Can set initially + st.Value(&withPointer).OldValue(&baseStruct).ExpectValid() + + // Can unset + st.Value(&baseStruct).OldValue(&withPointer).ExpectValid() + + // Cannot modify content + st.Value(&modifiedPointer).OldValue(&withPointer).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("structPointerNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Custom Type NoModify + old = baseStruct + withValue = baseStruct + withValue.CustomTypeNoModify = "custom-value" + + // Can set initially + st.Value(&withValue).OldValue(&old).ExpectValid() + + // Cannot modify + modified = baseStruct + modified.CustomTypeNoModify = "different-value" + st.Value(&modified).OldValue(&withValue).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("customTypeNoModify"), nil, "field cannot be modified once set").WithOrigin("update"), + }) + + // Custom Type NoSet + old = baseStruct + withValue = baseStruct + withValue.CustomTypeNoSet = 42 + + // Cannot set + st.Value(&withValue).OldValue(&old).ExpectMatches(field.ErrorMatcher{}.ByType().ByField().ByDetailSubstring().ByOrigin(), field.ErrorList{ + field.Invalid(field.NewPath("customTypeNoSet"), nil, "field cannot be set once created").WithOrigin("update"), + }) +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/zz_generated.validations.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/zz_generated.validations.go new file mode 100644 index 00000000000..57bc85bae15 --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/output_tests/tags/update/zz_generated.validations.go @@ -0,0 +1,514 @@ +//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 update + +import ( + context "context" + fmt "fmt" + + equality "k8s.io/apimachinery/pkg/api/equality" + 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 UpdateTestStruct + scheme.AddValidationFunc((*UpdateTestStruct)(nil), func(ctx context.Context, op operation.Operation, obj, oldObj interface{}) field.ErrorList { + switch op.Request.SubresourcePath() { + case "/": + return Validate_UpdateTestStruct(ctx, op, nil /* fldPath */, obj.(*UpdateTestStruct), safe.Cast[*UpdateTestStruct](oldObj)) + } + return field.ErrorList{field.InternalError(nil, fmt.Errorf("no validation found for %T, subresource: %v", obj, op.Request.SubresourcePath()))} + }) + return nil +} + +// Validate_UpdateTestStruct validates an instance of UpdateTestStruct according +// to declarative validation rules in the API schema. +func Validate_UpdateTestStruct(ctx context.Context, op operation.Operation, fldPath *field.Path, obj, oldObj *UpdateTestStruct) (errs field.ErrorList) { + // field UpdateTestStruct.TypeMeta has no validation + + // field UpdateTestStruct.StringNoSet + 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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoSet); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("stringNoSet"), &obj.StringNoSet, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return &oldObj.StringNoSet }))...) + + // field UpdateTestStruct.StringNoUnset + 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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoUnset); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("stringNoUnset"), &obj.StringNoUnset, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return &oldObj.StringNoUnset }))...) + + // field UpdateTestStruct.StringNoModify + 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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("stringNoModify"), &obj.StringNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return &oldObj.StringNoModify }))...) + + // field UpdateTestStruct.StringFullyRestricted + 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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoSet, validate.NoUnset, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("stringFullyRestricted"), &obj.StringFullyRestricted, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return &oldObj.StringFullyRestricted }))...) + + // field UpdateTestStruct.StringSetOnce + 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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoUnset, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("stringSetOnce"), &obj.StringSetOnce, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return &oldObj.StringSetOnce }))...) + + // field UpdateTestStruct.IntNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *int) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("intNoModify"), &obj.IntNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *int { return &oldObj.IntNoModify }))...) + + // field UpdateTestStruct.Int32NoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *int32) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("int32NoModify"), &obj.Int32NoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *int32 { return &oldObj.Int32NoModify }))...) + + // field UpdateTestStruct.Int64NoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *int64) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("int64NoModify"), &obj.Int64NoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *int64 { return &oldObj.Int64NoModify }))...) + + // field UpdateTestStruct.UintNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *uint) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("uintNoModify"), &obj.UintNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *uint { return &oldObj.UintNoModify }))...) + + // field UpdateTestStruct.BoolNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *bool) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("boolNoModify"), &obj.BoolNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *bool { return &oldObj.BoolNoModify }))...) + + // field UpdateTestStruct.Float32NoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *float32) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("float32NoModify"), &obj.Float32NoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *float32 { return &oldObj.Float32NoModify }))...) + + // field UpdateTestStruct.Float64NoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *float64) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("float64NoModify"), &obj.Float64NoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *float64 { return &oldObj.Float64NoModify }))...) + + // field UpdateTestStruct.ByteNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *byte) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("byteNoModify"), &obj.ByteNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *byte { return &oldObj.ByteNoModify }))...) + + // field UpdateTestStruct.StructNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *TestStruct) (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 + earlyReturn := false + if e := validate.UpdateStruct(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("structNoModify"), &obj.StructNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *TestStruct { return &oldObj.StructNoModify }))...) + + // field UpdateTestStruct.NonComparableStructNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *NonComparableStruct) (errs field.ErrorList) { + // don't revalidate unchanged data + if op.Type == operation.Update && equality.Semantic.DeepEqual(obj, oldObj) { + return nil + } + // call field-attached validations + earlyReturn := false + if e := validate.UpdateStruct(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("nonComparableStructNoModify"), &obj.NonComparableStructNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *NonComparableStruct { return &oldObj.NonComparableStructNoModify }))...) + + // field UpdateTestStruct.PointerNoSet + 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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoSet); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("pointerNoSet"), obj.PointerNoSet, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return oldObj.PointerNoSet }))...) + + // field UpdateTestStruct.PointerNoUnset + 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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoUnset); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("pointerNoUnset"), obj.PointerNoUnset, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return oldObj.PointerNoUnset }))...) + + // field UpdateTestStruct.PointerNoModify + 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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("pointerNoModify"), obj.PointerNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return oldObj.PointerNoModify }))...) + + // field UpdateTestStruct.PointerFullyRestricted + 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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoSet, validate.NoUnset, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("pointerFullyRestricted"), obj.PointerFullyRestricted, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *string { return oldObj.PointerFullyRestricted }))...) + + // field UpdateTestStruct.IntPointerNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *int) (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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("intPointerNoModify"), obj.IntPointerNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *int { return oldObj.IntPointerNoModify }))...) + + // field UpdateTestStruct.BoolPointerNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *bool) (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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("boolPointerNoModify"), obj.BoolPointerNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *bool { return oldObj.BoolPointerNoModify }))...) + + // field UpdateTestStruct.StructPointerNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *TestStruct) (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 + earlyReturn := false + if e := validate.UpdatePointer(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("structPointerNoModify"), obj.StructPointerNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *TestStruct { return oldObj.StructPointerNoModify }))...) + + // field UpdateTestStruct.CustomTypeNoModify + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *CustomString) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoModify); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("customTypeNoModify"), &obj.CustomTypeNoModify, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *CustomString { return &oldObj.CustomTypeNoModify }))...) + + // field UpdateTestStruct.CustomTypeNoSet + errs = append(errs, + func(fldPath *field.Path, obj, oldObj *CustomInt) (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 + earlyReturn := false + if e := validate.UpdateValueByCompare(ctx, op, fldPath, obj, oldObj, validate.NoSet); len(e) != 0 { + errs = append(errs, e...) + earlyReturn = true + } + if earlyReturn { + return // do not proceed + } + return + }(fldPath.Child("customTypeNoSet"), &obj.CustomTypeNoSet, safe.Field(oldObj, func(oldObj *UpdateTestStruct) *CustomInt { return &oldObj.CustomTypeNoSet }))...) + + return errs +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/update.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/update.go new file mode 100644 index 00000000000..973a90f018d --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/update.go @@ -0,0 +1,225 @@ +/* +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 validators + +import ( + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/api/validate" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/code-generator/cmd/validation-gen/util" + "k8s.io/gengo/v2/codetags" + "k8s.io/gengo/v2/types" +) + +const ( + updateTagName = "k8s:update" +) + +func init() { + shared := map[string]sets.Set[validate.UpdateConstraint]{} + RegisterFieldValidator(updateFieldValidator{byFieldPath: shared}) + RegisterTagValidator(updateTagCollector{byFieldPath: shared}) +} + +// updateTagCollector collects +k8s:update tags +type updateTagCollector struct { + byFieldPath map[string]sets.Set[validate.UpdateConstraint] +} + +func (updateTagCollector) Init(_ Config) {} + +func (updateTagCollector) TagName() string { + return updateTagName +} + +var updateTagValidScopes = sets.New(ScopeField) + +func (updateTagCollector) ValidScopes() sets.Set[Scope] { + return updateTagValidScopes +} + +func (utc updateTagCollector) GetValidations(context Context, tag codetags.Tag) (Validations, error) { + // Parse constraint from this tag + var constraint validate.UpdateConstraint + switch tag.Value { + case "NoSet": + constraint = validate.NoSet + case "NoUnset": + constraint = validate.NoUnset + case "NoModify": + constraint = validate.NoModify + default: + return Validations{}, fmt.Errorf("unknown +k8s:update constraint: %s", tag.Value) + } + + // Initialize set if doesn't exist + fieldPath := context.Path.String() + if utc.byFieldPath[fieldPath] == nil { + utc.byFieldPath[fieldPath] = sets.New[validate.UpdateConstraint]() + } + + // Add this constraint to the set for this field + utc.byFieldPath[fieldPath].Insert(constraint) + + if err := utc.validateConstraintsForType(context, utc.byFieldPath[fieldPath].UnsortedList()); err != nil { + return Validations{}, err + } + + // Don't generate validations here, just collect + return Validations{}, nil +} + +func (utc updateTagCollector) validateConstraintsForType(context Context, constraints []validate.UpdateConstraint) error { + t := util.NonPointer(util.NativeType(context.Type)) + isCompound := t.Kind == types.Slice || t.Kind == types.Map + isPointer := context.Type.Kind == types.Pointer + isStruct := t.Kind == types.Struct + + if isCompound { + for _, constraint := range constraints { + return fmt.Errorf("+k8s:update=%s is currently not supported on list or map fields", constraintName(constraint)) + } + } + + // For non-pointer struct fields, only NoModify is applicable + if isStruct && !isPointer { + for _, constraint := range constraints { + if constraint == validate.NoSet || constraint == validate.NoUnset { + return fmt.Errorf("+k8s:update=%s cannot be used on non-pointer struct fields (they cannot be unset)", constraintName(constraint)) + } + } + } + + return nil +} + +func constraintName(c validate.UpdateConstraint) string { + switch c { + case validate.NoSet: + return "NoSet" + case validate.NoUnset: + return "NoUnset" + case validate.NoModify: + return "NoModify" + default: + return fmt.Sprintf("Unknown(%d)", c) + } +} + +func (utc updateTagCollector) Docs() TagDoc { + return TagDoc{ + Tag: utc.TagName(), + Scopes: utc.ValidScopes().UnsortedList(), + PayloadsType: codetags.ValueTypeString, + Description: "Provides constraints on the allowed update operations of a field. " + + "Currently supports non-list and non-map fields only. " + + "Constraints: NoSet (prevents unset->set transitions), NoUnset (prevents set->unset transitions), " + + "NoModify (prevents value changes but allows set/unset transitions). " + + "Multiple constraints can be specified using multiple tags. " + + "For non-pointer structs, NoSet and NoUnset have no effect as these fields cannot be unset. " + + "Future support planned for lists/maps with NoAddItem and NoRemoveItem constraints. " + + "Examples: +k8s:update=NoModify +k8s:update=NoUnset for set-once fields; " + + "+k8s:update=NoSet for fields that must be set at creation or never.", + } +} + +// updateFieldValidator processes all collected update tags and generates validations +type updateFieldValidator struct { + byFieldPath map[string]sets.Set[validate.UpdateConstraint] +} + +func (updateFieldValidator) Init(_ Config) {} + +func (updateFieldValidator) Name() string { + return "updateFieldValidator" +} + +var ( + updateValueValidator = types.Name{Package: libValidationPkg, Name: "UpdateValueByCompare"} + updatePointerValidator = types.Name{Package: libValidationPkg, Name: "UpdatePointer"} + updateValueByReflectValidator = types.Name{Package: libValidationPkg, Name: "UpdateValueByReflect"} + updateStructValidator = types.Name{Package: libValidationPkg, Name: "UpdateStruct"} + + // Constraint constants that will be used as arguments + noSetConstraint = types.Name{Package: libValidationPkg, Name: "NoSet"} + noUnsetConstraint = types.Name{Package: libValidationPkg, Name: "NoUnset"} + noModifyConstraint = types.Name{Package: libValidationPkg, Name: "NoModify"} +) + +func (ufv updateFieldValidator) GetValidations(context Context) (Validations, error) { + constraintSet, ok := ufv.byFieldPath[context.Path.String()] + if !ok || constraintSet.Len() == 0 { + return Validations{}, nil + } + + constraints := constraintSet.UnsortedList() + + t := util.NonPointer(util.NativeType(context.Type)) + if t.Kind == types.Slice || t.Kind == types.Map { + // TODO: add support for list and map fields + return Validations{}, fmt.Errorf("update constraints are currently not supported on list or map fields") + } + + return ufv.generateValidation(context, constraints) +} + +func (ufv updateFieldValidator) generateValidation(context Context, constraints []validate.UpdateConstraint) (Validations, error) { + var result Validations + + // Determine the appropriate validator function based on field type + t := util.NonPointer(util.NativeType(context.Type)) + isPointer := context.Type.Kind == types.Pointer + isStruct := t.Kind == types.Struct + isComparable := util.IsDirectComparable(t) + + var validatorFunc types.Name + if isPointer { + validatorFunc = updatePointerValidator + } else if isStruct { + validatorFunc = updateStructValidator + } else if isComparable { + validatorFunc = updateValueValidator + } else { + validatorFunc = updateValueByReflectValidator + } + + // Sort constraints to ensure deterministic order + sort.Slice(constraints, func(i, j int) bool { + return constraints[i] < constraints[j] + }) + + // Build the constraint arguments in deterministic order + var constraintArgs []any + for _, constraint := range constraints { + switch constraint { + case validate.NoSet: + constraintArgs = append(constraintArgs, Identifier(noSetConstraint)) + case validate.NoUnset: + constraintArgs = append(constraintArgs, Identifier(noUnsetConstraint)) + case validate.NoModify: + constraintArgs = append(constraintArgs, Identifier(noModifyConstraint)) + } + } + + // Use ShortCircuit flag so these run in the same group as +k8s:optional + fn := Function(updateTagName, ShortCircuit, validatorFunc, constraintArgs...) + result.AddFunction(fn) + + return result, nil +}