diff --git a/docs/validations.md b/docs/validations.md index 8f9f731..0b066f7 100644 --- a/docs/validations.md +++ b/docs/validations.md @@ -218,3 +218,27 @@ validations: configuration: removalPolicy: Allow ``` + +### xvalidations + +Validates compatibility of changes to a property's `x-kubernetes-validations` rules. +Rules are identified by a hash of their semantically significant fields: `rule`, `reason`, and `optionalOldSelf`. +Changes to `message`, `messageExpression`, and `fieldPath` are not tracked and will never be flagged. + +#### Configuration + +The `xvalidations` validation can be configured to allow changes: + +- `additionPolicy` - controls whether adding a new rule is considered compatible. Allowed values are `Allow` and `Disallow`. When set to `Allow`, the validation does not flag this change. The default is `Disallow` to ensure new server-side restrictions are reviewed. +- `removalPolicy` - controls whether removing an existing rule is considered compatible. Allowed values are `Allow` and `Disallow`. When set to `Allow`, the validation does not flag this change. The default is `Disallow` to protect clients that rely on existing validation behavior. + +Example configuration that allows adding and removing rules: + +```yaml +validations: + - name: xvalidations + enforcement: Error + configuration: + additionPolicy: Allow + removalPolicy: Allow +``` diff --git a/examples/config.yaml b/examples/config.yaml index 9a3358c..1437f0f 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -23,3 +23,9 @@ validations: configuration: additionPolicy: Allow removalPolicy: Allow + # example of configuring xvalidations validation to allow adding and removing x-kubernetes-validation rules + - name: xvalidations + enforcement: Error + configuration: + additionPolicy: Allow + removalPolicy: Allow diff --git a/pkg/runner/registry.go b/pkg/runner/registry.go index 545b9d9..72a23a7 100644 --- a/pkg/runner/registry.go +++ b/pkg/runner/registry.go @@ -45,6 +45,7 @@ func init() { property.RegisterPattern(defaultRegistry) property.RegisterNullable(defaultRegistry) property.RegisterOneOf(defaultRegistry) + property.RegisterXValidations(defaultRegistry) } // DefaultRegistry returns a pre-configured validations.Registry. diff --git a/pkg/validations/property/xvalidations.go b/pkg/validations/property/xvalidations.go new file mode 100644 index 0000000..d35090c --- /dev/null +++ b/pkg/validations/property/xvalidations.go @@ -0,0 +1,202 @@ +// 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. + +package property + +import ( + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/utils/set" + "sigs.k8s.io/crdify/pkg/config" + "sigs.k8s.io/crdify/pkg/validations" +) + +var ( + _ validations.Validation = (*XValidations)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*XValidations)(nil) +) + +const validationName = "xvalidations" + +// RegisterXValidations registers the XValidations validation +// with the provided validation registry. +func RegisterXValidations(registry validations.Registry) { + registry.Register(validationName, factoryXValidations) +} + +// factoryXValidations is a function used to initialize an XValidations validation +// implementation based on the provided configuration. +func factoryXValidations(cfg map[string]interface{}) (validations.Validation, error) { + xvCfg := &XValidationsConfig{} + + if err := ConfigToType(cfg, xvCfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + err := ValidateXValidationsConfig(xvCfg) + if err != nil { + return nil, fmt.Errorf("validating XValidations config: %w", err) + } + + return &XValidations{XValidationsConfig: *xvCfg}, nil +} + +// ValidateXValidationsConfig ensures provided XValidationsConfig is valid and defaults missing values. +func ValidateXValidationsConfig(in *XValidationsConfig) error { + if in == nil { + return nil + } + + switch in.AdditionPolicy { + case XValidationsAdditionPolicyAllow, XValidationsAdditionPolicyDisallow: + // valid entries + case XValidationsAdditionPolicy(""): + in.AdditionPolicy = XValidationsAdditionPolicyDisallow + default: + return fmt.Errorf("%w : %q (valid values: %q, %q)", errUnknownXValidationsAdditionPolicy, in.AdditionPolicy, XValidationsAdditionPolicyAllow, XValidationsAdditionPolicyDisallow) + } + + switch in.RemovalPolicy { + case XValidationsRemovalPolicyAllow, XValidationsRemovalPolicyDisallow: + // valid entries + case XValidationsRemovalPolicy(""): + in.RemovalPolicy = XValidationsRemovalPolicyDisallow + default: + return fmt.Errorf("%w : %q (valid values: %q, %q)", errUnknownXValidationsRemovalPolicy, in.RemovalPolicy, XValidationsRemovalPolicyAllow, XValidationsRemovalPolicyDisallow) + } + + return nil +} + +var errUnknownXValidationsAdditionPolicy = errors.New("unknown addition policy") +var errUnknownXValidationsRemovalPolicy = errors.New("unknown removal policy") + +// XValidationsAdditionPolicy represents how adding a new x-kubernetes-validation rule should be evaluated. +type XValidationsAdditionPolicy string + +const ( + // XValidationsAdditionPolicyAllow treats adding a new x-kubernetes-validation rule as compatible. + XValidationsAdditionPolicyAllow XValidationsAdditionPolicy = "Allow" + // XValidationsAdditionPolicyDisallow treats adding a new x-kubernetes-validation rule as incompatible. + XValidationsAdditionPolicyDisallow XValidationsAdditionPolicy = "Disallow" +) + +// XValidationsRemovalPolicy represents how removing an existing x-kubernetes-validation rule should be evaluated. +type XValidationsRemovalPolicy string + +const ( + // XValidationsRemovalPolicyAllow treats removing an existing x-kubernetes-validation rule as compatible. + XValidationsRemovalPolicyAllow XValidationsRemovalPolicy = "Allow" + // XValidationsRemovalPolicyDisallow treats removing an existing x-kubernetes-validation rule as incompatible. + XValidationsRemovalPolicyDisallow XValidationsRemovalPolicy = "Disallow" +) + +// XValidationsConfig contains additional configuration for the XValidations validation. +type XValidationsConfig struct { + // AdditionPolicy dictates whether adding a new x-kubernetes-validation rule is compatible. + // Allowed values are Allow and Disallow. Defaults to Disallow. + AdditionPolicy XValidationsAdditionPolicy `json:"additionPolicy,omitempty"` + // RemovalPolicy dictates whether removing an existing x-kubernetes-validation rule is compatible. + // Allowed values are Allow and Disallow. Defaults to Disallow. + RemovalPolicy XValidationsRemovalPolicy `json:"removalPolicy,omitempty"` +} + +// XValidations is a Validation that can be used to identify +// incompatible changes to the x-kubernetes-validations of CRD properties. +type XValidations struct { + XValidationsConfig + enforcement config.EnforcementPolicy +} + +// Name returns the name of the XValidations validation. +func (x *XValidations) Name() string { + return validationName +} + +// SetEnforcement sets the EnforcementPolicy for the XValidations validation. +func (x *XValidations) SetEnforcement(policy config.EnforcementPolicy) { + x.enforcement = policy +} + +// hashValidationRule returns a stable SHA-256 hash of the semantically +// significant fields of a ValidationRule. Only Rule, Reason, and OptionalOldSelf are included. +func hashValidationRule(validationRule apiextensionsv1.ValidationRule) string { + strToHash := fmt.Sprintf("%v", validationRule.Rule) + + if validationRule.Reason != nil { + strToHash += fmt.Sprintf("%v", *validationRule.Reason) + } + + if validationRule.OptionalOldSelf != nil { + strToHash += fmt.Sprintf("%v", *validationRule.OptionalOldSelf) + } + + h := sha256.New() + + h.Write([]byte(strToHash)) + + return string(h.Sum(nil)) +} + +// Compare compares an old and a new JSONSchemaProps, checking for +// incompatible changes to the x-kubernetes-validations of a property. +func (x *XValidations) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + oldXValidations := map[string]apiextensionsv1.ValidationRule{} + newXValidations := map[string]apiextensionsv1.ValidationRule{} + + oldHashes := set.New[string]() + newHashes := set.New[string]() + + for _, oldVal := range a.XValidations { + hash := hashValidationRule(oldVal) + oldXValidations[hash] = oldVal + oldHashes.Insert(hash) + } + + for _, newVal := range b.XValidations { + hash := hashValidationRule(newVal) + newXValidations[hash] = newVal + newHashes.Insert(hash) + } + + errs := []error{} + + oldRemovedHashes := oldHashes.Difference(newHashes) + for _, hash := range oldRemovedHashes.SortedList() { + r, _ := json.Marshal(oldXValidations[hash]) + errs = append(errs, fmt.Errorf("%w: %s", ErrXValidationRemoved, string(r))) + } + + newAddedHashes := newHashes.Difference(oldHashes) + for _, hash := range newAddedHashes.SortedList() { + r, _ := json.Marshal(newXValidations[hash]) + errs = append(errs, fmt.Errorf("%w: %s", ErrXValidationAdded, string(r))) + } + + a.XValidations = nil + b.XValidations = nil + + return validations.HandleErrors(x.Name(), x.enforcement, errs...) +} + +var ( + // ErrXValidationAdded represents an error state when an x-kubernetes-validation rule is added to a property. + ErrXValidationAdded = errors.New("x-kubernetes-validations added") + // ErrXValidationRemoved represents an error state when an x-kubernetes-validation rule is removed from a property. + ErrXValidationRemoved = errors.New("x-kubernetes-validations removed") +) diff --git a/pkg/validations/property/xvalidations_test.go b/pkg/validations/property/xvalidations_test.go new file mode 100644 index 0000000..8b92e5c --- /dev/null +++ b/pkg/validations/property/xvalidations_test.go @@ -0,0 +1,258 @@ +// 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. + +package property + +import ( + "testing" + + "k8s.io/utils/ptr" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + internaltesting "sigs.k8s.io/crdify/pkg/validations/internal/testing" +) + +// propsWithRules is a helper that returns a JSONSchemaProps populated with the provided ValidationRules +func propsWithRules(rules ...apiextensionsv1.ValidationRule) *apiextensionsv1.JSONSchemaProps { + return &apiextensionsv1.JSONSchemaProps{XValidations: rules} +} + +func TestXValidations(t *testing.T) { + testcases := []internaltesting.Testcase[apiextensionsv1.JSONSchemaProps]{ + { + Name: "identical rules, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "both empty, not flagged", + Old: &apiextensionsv1.JSONSchemaProps{}, + New: &apiextensionsv1.JSONSchemaProps{}, + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "rule added from empty, flagged", + Old: &apiextensionsv1.JSONSchemaProps{}, + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "all rules removed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: &apiextensionsv1.JSONSchemaProps{}, + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "rule added, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + apiextensionsv1.ValidationRule{Rule: "self.size() > 0", Message: "must not be empty"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "rule removed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + apiextensionsv1.ValidationRule{Rule: "self.size() > 0", Message: "must not be empty"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "rule expression modified, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self != oldSelf", Message: "field is immutable"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "optionalOldSelf added, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", OptionalOldSelf: ptr.To(true)}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "optionalOldSelf removed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", OptionalOldSelf: ptr.To(true)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "optionalOldSelf changed true->false, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", OptionalOldSelf: ptr.To(true)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", OptionalOldSelf: ptr.To(false)}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "reason added, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", Reason: ptr.To(apiextensionsv1.FieldValueForbidden)}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "reason removed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", Reason: ptr.To(apiextensionsv1.FieldValueForbidden)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "reason changed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", Reason: ptr.To(apiextensionsv1.FieldValueForbidden)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", Reason: ptr.To(apiextensionsv1.FieldValueInvalid)}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "message changed, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "original immutable message"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "different message text"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "messageExpression changed, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", MessageExpression: "'original expr'"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", MessageExpression: "'different expr'"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "fieldPath changed, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", FieldPath: ".field"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable", FieldPath: ".changedField"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "multiple rules with identical Rule expression, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "A: field is immutable", OptionalOldSelf: ptr.To(true)}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "B: field is immutable", OptionalOldSelf: ptr.To(false)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "A: field is immutable", OptionalOldSelf: ptr.To(true)}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "B: field is immutable", OptionalOldSelf: ptr.To(false)}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "one of multiple same-Rule rules removed, flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "A: field is immutable", OptionalOldSelf: ptr.To(true)}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "B: field is immutable", OptionalOldSelf: ptr.To(false)}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "A: field is immutable", OptionalOldSelf: ptr.To(true)}, + ), + Flagged: true, + ComparableValidation: &XValidations{}, + }, + { + Name: "rules reordered, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self.size() > 0", Message: "must not be empty"}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + apiextensionsv1.ValidationRule{Rule: "self.matches('[a-z]+')", Message: "must be lowercase"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self.matches('[a-z]+')", Message: "must be lowercase"}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + apiextensionsv1.ValidationRule{Rule: "self.size() > 0", Message: "must not be empty"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + { + Name: "duplicate identical rules collapse, not flagged", + Old: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + New: propsWithRules( + apiextensionsv1.ValidationRule{Rule: "self == oldSelf", Message: "field is immutable"}, + ), + Flagged: false, + ComparableValidation: &XValidations{}, + }, + } + + internaltesting.RunTestcases(t, testcases...) +} diff --git a/test/xvalidationsadded/a.yaml b/test/xvalidationsadded/a.yaml new file mode 100644 index 0000000..9a6fdf7 --- /dev/null +++ b/test/xvalidationsadded/a.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsadded/b.yaml b/test/xvalidationsadded/b.yaml new file mode 100644 index 0000000..4d7468d --- /dev/null +++ b/test/xvalidationsadded/b.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + - rule: "self.size() > 0" + message: "must not be empty" diff --git a/test/xvalidationsadded/expected.json b/test/xvalidationsadded/expected.json new file mode 100644 index 0000000..ce31816 --- /dev/null +++ b/test/xvalidationsadded/expected.json @@ -0,0 +1,20 @@ +{ + "sameVersionValidation": [ + { + "version": "v1", + "propertyComparisons": [ + { + "property": "^.spec.code", + "comparisonResults": [ + { + "name": "xvalidations", + "errors": [ + "x-kubernetes-validations added: {\"rule\":\"self.size() \\u003e 0\",\"message\":\"must not be empty\"}" + ] + } + ] + } + ] + } + ] +} diff --git a/test/xvalidationsmessageonly/a.yaml b/test/xvalidationsmessageonly/a.yaml new file mode 100644 index 0000000..9a6fdf7 --- /dev/null +++ b/test/xvalidationsmessageonly/a.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsmessageonly/b.yaml b/test/xvalidationsmessageonly/b.yaml new file mode 100644 index 0000000..179fb2d --- /dev/null +++ b/test/xvalidationsmessageonly/b.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "code may not be modified once set" diff --git a/test/xvalidationsmessageonly/expected.json b/test/xvalidationsmessageonly/expected.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/xvalidationsmessageonly/expected.json @@ -0,0 +1 @@ +{} diff --git a/test/xvalidationsoptionaloldselfadded/a.yaml b/test/xvalidationsoptionaloldselfadded/a.yaml new file mode 100644 index 0000000..9a6fdf7 --- /dev/null +++ b/test/xvalidationsoptionaloldselfadded/a.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsoptionaloldselfadded/b.yaml b/test/xvalidationsoptionaloldselfadded/b.yaml new file mode 100644 index 0000000..c5c784b --- /dev/null +++ b/test/xvalidationsoptionaloldselfadded/b.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + optionalOldSelf: true diff --git a/test/xvalidationsoptionaloldselfadded/expected.json b/test/xvalidationsoptionaloldselfadded/expected.json new file mode 100644 index 0000000..be71263 --- /dev/null +++ b/test/xvalidationsoptionaloldselfadded/expected.json @@ -0,0 +1,21 @@ +{ + "sameVersionValidation": [ + { + "version": "v1", + "propertyComparisons": [ + { + "property": "^.spec.code", + "comparisonResults": [ + { + "name": "xvalidations", + "errors": [ + "x-kubernetes-validations removed: {\"rule\":\"self == oldSelf\",\"message\":\"field is immutable\"}", + "x-kubernetes-validations added: {\"rule\":\"self == oldSelf\",\"message\":\"field is immutable\",\"optionalOldSelf\":true}" + ] + } + ] + } + ] + } + ] +} diff --git a/test/xvalidationsreasonchanged/a.yaml b/test/xvalidationsreasonchanged/a.yaml new file mode 100644 index 0000000..6b43b7b --- /dev/null +++ b/test/xvalidationsreasonchanged/a.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + reason: FieldValueForbidden diff --git a/test/xvalidationsreasonchanged/b.yaml b/test/xvalidationsreasonchanged/b.yaml new file mode 100644 index 0000000..6a7aeaf --- /dev/null +++ b/test/xvalidationsreasonchanged/b.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + reason: FieldValueInvalid diff --git a/test/xvalidationsreasonchanged/expected.json b/test/xvalidationsreasonchanged/expected.json new file mode 100644 index 0000000..72f9f7b --- /dev/null +++ b/test/xvalidationsreasonchanged/expected.json @@ -0,0 +1,21 @@ +{ + "sameVersionValidation": [ + { + "version": "v1", + "propertyComparisons": [ + { + "property": "^.spec.code", + "comparisonResults": [ + { + "name": "xvalidations", + "errors": [ + "x-kubernetes-validations removed: {\"rule\":\"self == oldSelf\",\"message\":\"field is immutable\",\"reason\":\"FieldValueForbidden\"}", + "x-kubernetes-validations added: {\"rule\":\"self == oldSelf\",\"message\":\"field is immutable\",\"reason\":\"FieldValueInvalid\"}" + ] + } + ] + } + ] + } + ] +} diff --git a/test/xvalidationsremoved/a.yaml b/test/xvalidationsremoved/a.yaml new file mode 100644 index 0000000..4d7468d --- /dev/null +++ b/test/xvalidationsremoved/a.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + - rule: "self.size() > 0" + message: "must not be empty" diff --git a/test/xvalidationsremoved/b.yaml b/test/xvalidationsremoved/b.yaml new file mode 100644 index 0000000..9a6fdf7 --- /dev/null +++ b/test/xvalidationsremoved/b.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsremoved/expected.json b/test/xvalidationsremoved/expected.json new file mode 100644 index 0000000..765ac6e --- /dev/null +++ b/test/xvalidationsremoved/expected.json @@ -0,0 +1,20 @@ +{ + "sameVersionValidation": [ + { + "version": "v1", + "propertyComparisons": [ + { + "property": "^.spec.code", + "comparisonResults": [ + { + "name": "xvalidations", + "errors": [ + "x-kubernetes-validations removed: {\"rule\":\"self.size() \\u003e 0\",\"message\":\"must not be empty\"}" + ] + } + ] + } + ] + } + ] +} diff --git a/test/xvalidationsreordered/a.yaml b/test/xvalidationsreordered/a.yaml new file mode 100644 index 0000000..a77f909 --- /dev/null +++ b/test/xvalidationsreordered/a.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" + - rule: "self.size() > 0" + message: "must not be empty" + - rule: "self.matches('[a-z]+')" + message: "must be lowercase" diff --git a/test/xvalidationsreordered/b.yaml b/test/xvalidationsreordered/b.yaml new file mode 100644 index 0000000..a1d04cb --- /dev/null +++ b/test/xvalidationsreordered/b.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self.matches('[a-z]+')" + message: "must be lowercase" + - rule: "self == oldSelf" + message: "field is immutable" + - rule: "self.size() > 0" + message: "must not be empty" diff --git a/test/xvalidationsreordered/expected.json b/test/xvalidationsreordered/expected.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/test/xvalidationsreordered/expected.json @@ -0,0 +1 @@ +{} diff --git a/test/xvalidationsrulemodified/a.yaml b/test/xvalidationsrulemodified/a.yaml new file mode 100644 index 0000000..9a6fdf7 --- /dev/null +++ b/test/xvalidationsrulemodified/a.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self == oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsrulemodified/b.yaml b/test/xvalidationsrulemodified/b.yaml new file mode 100644 index 0000000..9c4fcb3 --- /dev/null +++ b/test/xvalidationsrulemodified/b.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xvalidationexamples.example.com +spec: + group: example.com + names: + kind: XValidationExample + listKind: XValidationExampleList + plural: xvalidationexamples + singular: xvalidationexample + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + code: + type: string + x-kubernetes-validations: + - rule: "self != oldSelf" + message: "field is immutable" diff --git a/test/xvalidationsrulemodified/expected.json b/test/xvalidationsrulemodified/expected.json new file mode 100644 index 0000000..d9f4ffa --- /dev/null +++ b/test/xvalidationsrulemodified/expected.json @@ -0,0 +1,21 @@ +{ + "sameVersionValidation": [ + { + "version": "v1", + "propertyComparisons": [ + { + "property": "^.spec.code", + "comparisonResults": [ + { + "name": "xvalidations", + "errors": [ + "x-kubernetes-validations removed: {\"rule\":\"self == oldSelf\",\"message\":\"field is immutable\"}", + "x-kubernetes-validations added: {\"rule\":\"self != oldSelf\",\"message\":\"field is immutable\"}" + ] + } + ] + } + ] + } + ] +}