From a2f47e6586f3e7362d7354c35df8e1cfbb86af8f Mon Sep 17 00:00:00 2001 From: Joe Betz Date: Mon, 3 Mar 2025 09:49:51 -0500 Subject: [PATCH] Add validators: immutable Co-authored-by: Tim Hockin Co-authored-by: Aaron Prindle Co-authored-by: Yongrui Lin --- .../pkg/api/validate/immutable.go | 60 +++++++++ .../pkg/api/validate/immutable_test.go | 117 ++++++++++++++++++ .../validation-gen/validators/immutable.go | 77 ++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/immutable.go create mode 100644 staging/src/k8s.io/apimachinery/pkg/api/validate/immutable_test.go create mode 100644 staging/src/k8s.io/code-generator/cmd/validation-gen/validators/immutable.go diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable.go new file mode 100644 index 00000000000..6f92dc86a0d --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable.go @@ -0,0 +1,60 @@ +/* +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" +) + +// Immutable verifies that the specified value has not changed in the course of +// an update operation. It does nothing if the old value is not provided. If +// the caller needs to compare types that are not trivially comparable, they +// should use ImmutableNonComparable instead. +func Immutable[T comparable](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue *T) field.ErrorList { + if op.Type != operation.Update { + return nil + } + if value == nil && oldValue == nil { + return nil + } + if value == nil || oldValue == nil || *value != *oldValue { + return field.ErrorList{ + field.Forbidden(fldPath, "field is immutable"), + } + } + return nil +} + +// ImmutableNonComparable verifies that the specified value has not changed in +// the course of an update operation. It does nothing if the old value is not +// provided. Unlike Immutable, this function can be used with types that are +// not directly comparable, at the cost of performance. +func ImmutableNonComparable[T any](_ context.Context, op operation.Operation, fldPath *field.Path, value, oldValue T) field.ErrorList { + if op.Type != operation.Update { + return nil + } + if !equality.Semantic.DeepEqual(value, oldValue) { + return field.ErrorList{ + field.Forbidden(fldPath, "field is immutable"), + } + } + return nil +} diff --git a/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable_test.go b/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable_test.go new file mode 100644 index 00000000000..50329832e3c --- /dev/null +++ b/staging/src/k8s.io/apimachinery/pkg/api/validate/immutable_test.go @@ -0,0 +1,117 @@ +/* +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" + "k8s.io/utils/ptr" +) + +type Struct struct { + S string + I int + B bool +} + +func TestImmutable(t *testing.T) { + structA := Struct{"abc", 123, true} + structB := Struct{"xyz", 456, false} + + for _, tc := range []struct { + name string + fn func(operation.Operation, *field.Path) field.ErrorList + fail bool + }{{ + name: "nil both values", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable[int](context.Background(), op, fld, nil, nil) + }, + }, { + name: "nil value", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, nil, ptr.To(123)) + }, + fail: true, + }, { + name: "nil oldValue", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(123), nil) + }, + fail: true, + }, { + name: "int", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(123), ptr.To(123)) + }, + }, { + name: "int fail", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(123), ptr.To(456)) + }, + fail: true, + }, { + name: "string", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To("abc"), ptr.To("abc")) + }, + }, { + name: "string fail", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To("abc"), ptr.To("xyz")) + }, + fail: true, + }, { + name: "bool", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(true), ptr.To(true)) + }, + }, { + name: "bool fail", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(true), ptr.To(false)) + }, + fail: true, + }, { + name: "struct", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(structA), ptr.To(structA)) + }, + }, { + name: "struct fail", + fn: func(op operation.Operation, fld *field.Path) field.ErrorList { + return Immutable(context.Background(), op, fld, ptr.To(structA), ptr.To(structB)) + }, + fail: true, + }} { + t.Run(tc.name, func(t *testing.T) { + errs := tc.fn(operation.Operation{Type: operation.Create}, field.NewPath("")) + if len(errs) != 0 { // Create should always succeed + t.Errorf("case %q (create): expected success: %v", tc.name, errs) + } + errs = tc.fn(operation.Operation{Type: operation.Update}, field.NewPath("")) + if tc.fail && len(errs) == 0 { + t.Errorf("case %q (update): expected failure", tc.name) + } else if !tc.fail && len(errs) != 0 { + t.Errorf("case %q (update): expected success: %v", tc.name, errs) + } + }) + } +} diff --git a/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/immutable.go b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/immutable.go new file mode 100644 index 00000000000..99c5479626d --- /dev/null +++ b/staging/src/k8s.io/code-generator/cmd/validation-gen/validators/immutable.go @@ -0,0 +1,77 @@ +/* +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 ( + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/gengo/v2/types" +) + +const ( + immutableTagName = "k8s:immutable" +) + +func init() { + RegisterTagValidator(immutableTagValidator{}) +} + +type immutableTagValidator struct{} + +func (immutableTagValidator) Init(_ Config) {} + +func (immutableTagValidator) TagName() string { + return immutableTagName +} + +var immutableTagValidScopes = sets.New(ScopeField, ScopeType, ScopeMapVal, ScopeListVal) + +func (immutableTagValidator) ValidScopes() sets.Set[Scope] { + return immutableTagValidScopes +} + +var ( + immutableValidator = types.Name{Package: libValidationPkg, Name: "Immutable"} + immutableNonComparableValidator = types.Name{Package: libValidationPkg, Name: "ImmutableNonComparable"} +) + +func (immutableTagValidator) GetValidations(context Context, _ []string, payload string) (Validations, error) { + var result Validations + + t := context.Type + for t.Kind == types.Pointer || t.Kind == types.Alias { + if t.Kind == types.Pointer { + t = t.Elem + } else if t.Kind == types.Alias { + t = t.Underlying + } + } + if t.IsComparable() { + result.AddFunction(Function(immutableTagName, DefaultFlags, immutableValidator)) + } else { + result.AddFunction(Function(immutableTagName, DefaultFlags, immutableNonComparableValidator)) + } + + return result, nil +} + +func (itv immutableTagValidator) Docs() TagDoc { + return TagDoc{ + Tag: itv.TagName(), + Scopes: itv.ValidScopes().UnsortedList(), + Description: "Indicates that a field may not be updated.", + } +}