diff --git a/go.mod b/go.mod index 54dcd8959..d493faafc 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,6 @@ require ( github.com/klauspost/compress v1.18.0 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.1.1 - github.com/openshift/crd-schema-checker v0.0.0-20240404194209-35a9033b1d11 github.com/operator-framework/api v0.30.0 github.com/operator-framework/helm-operator-plugins v0.8.0 github.com/operator-framework/operator-registry v1.51.0 diff --git a/go.sum b/go.sum index 2e04128ca..4b6ac7ed1 100644 --- a/go.sum +++ b/go.sum @@ -396,8 +396,6 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/runtime-spec v1.2.1 h1:S4k4ryNgEpxW1dzyqffOmhI1BHYcjzU8lpJfSlR0xww= github.com/opencontainers/runtime-spec v1.2.1/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= -github.com/openshift/crd-schema-checker v0.0.0-20240404194209-35a9033b1d11 h1:eTNDkNRNV5lZvUbVM9Nop0lBcljSnA8rZX6yQPZ0ZnU= -github.com/openshift/crd-schema-checker v0.0.0-20240404194209-35a9033b1d11/go.mod h1:EmVJt97N+pfWFsli/ipXTBZqSG5F5KGQhm3c3IsGq1o= github.com/operator-framework/api v0.30.0 h1:44hCmGnEnZk/Miol5o44dhSldNH0EToQUG7vZTl29kk= github.com/operator-framework/api v0.30.0/go.mod h1:FYxAPhjtlXSAty/fbn5YJnFagt6SpJZJgFNNbvDe5W0= github.com/operator-framework/helm-operator-plugins v0.8.0 h1:0f6HOQC5likkf0b/OvGvw7nhDb6h8Cj5twdCNjwNzMc= diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator.go deleted file mode 100644 index 4678b2de0..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator.go +++ /dev/null @@ -1,171 +0,0 @@ -// Originally copied from https://github.com/carvel-dev/kapp/tree/d7fc2e15439331aa3a379485bb124e91a0829d2e -// Attribution: -// Copyright 2024 The Carvel Authors. -// SPDX-License-Identifier: Apache-2.0 - -package crdupgradesafety - -import ( - "errors" - "fmt" - "maps" - "reflect" - "slices" - - "github.com/openshift/crd-schema-checker/pkg/manifestcomparators" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -// ChangeValidation is a function that accepts a FieldDiff -// as a parameter and should return: -// - a boolean representation of whether or not the change -// - an error if the change would be unsafe -// has been fully handled (i.e no additional changes exist) -type ChangeValidation func(diff FieldDiff) (bool, error) - -// ChangeValidator is a Validation implementation focused on -// handling updates to existing fields in a CRD -type ChangeValidator struct { - // Validations is a slice of ChangeValidations - // to run against each changed field - Validations []ChangeValidation -} - -func (cv *ChangeValidator) Name() string { - return "ChangeValidator" -} - -// Validate will compare each version in the provided existing and new CRDs. -// Since the ChangeValidator is tailored to handling updates to existing fields in -// each version of a CRD. As such the following is assumed: -// - Validating the removal of versions during an update is handled outside of this -// validator. If a version in the existing version of the CRD does not exist in the new -// version that version of the CRD is skipped in this validator. -// - Removal of existing fields is unsafe. Regardless of whether or not this is handled -// by a validator outside this one, if a field is present in a version provided by the existing CRD -// but not present in the same version provided by the new CRD this validation will fail. -// -// Additionally, any changes that are not validated and handled by the known ChangeValidations -// are deemed as unsafe and returns an error. -func (cv *ChangeValidator) Validate(old, new apiextensionsv1.CustomResourceDefinition) error { - errs := []error{} - for _, version := range old.Spec.Versions { - newVersion := manifestcomparators.GetVersionByName(&new, version.Name) - if newVersion == nil { - // if the new version doesn't exist skip this version - continue - } - flatOld := FlattenSchema(version.Schema.OpenAPIV3Schema) - flatNew := FlattenSchema(newVersion.Schema.OpenAPIV3Schema) - - diffs, err := CalculateFlatSchemaDiff(flatOld, flatNew) - if err != nil { - errs = append(errs, fmt.Errorf("calculating schema diff for CRD version %q", version.Name)) - continue - } - - for _, field := range slices.Sorted(maps.Keys(diffs)) { - diff := diffs[field] - - handled := false - for _, validation := range cv.Validations { - ok, err := validation(diff) - if err != nil { - errs = append(errs, fmt.Errorf("version %q, field %q: %w", version.Name, field, err)) - } - if ok { - handled = true - break - } - } - - if !handled { - errs = append(errs, fmt.Errorf("version %q, field %q has unknown change, refusing to determine that change is safe", version.Name, field)) - } - } - } - - if len(errs) > 0 { - return errors.Join(errs...) - } - return nil -} - -type FieldDiff struct { - Old *apiextensionsv1.JSONSchemaProps - New *apiextensionsv1.JSONSchemaProps -} - -// FlatSchema is a flat representation of a CRD schema. -type FlatSchema map[string]*apiextensionsv1.JSONSchemaProps - -// FlattenSchema takes in a CRD version OpenAPIV3Schema and returns -// a flattened representation of it. For example, a CRD with a schema of: -// ```yaml -// -// ... -// spec: -// type: object -// properties: -// foo: -// type: string -// bar: -// type: string -// ... -// -// ``` -// would be represented as: -// -// map[string]*apiextensionsv1.JSONSchemaProps{ -// "^": {}, -// "^.spec": {}, -// "^.spec.foo": {}, -// "^.spec.bar": {}, -// } -// -// where "^" represents the "root" schema -func FlattenSchema(schema *apiextensionsv1.JSONSchemaProps) FlatSchema { - fieldMap := map[string]*apiextensionsv1.JSONSchemaProps{} - - manifestcomparators.SchemaHas(schema, - field.NewPath("^"), - field.NewPath("^"), - nil, - func(s *apiextensionsv1.JSONSchemaProps, _, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - fieldMap[simpleLocation.String()] = s.DeepCopy() - return false - }) - - return fieldMap -} - -// CalculateFlatSchemaDiff finds fields in a FlatSchema that are different -// and returns a mapping of field --> old and new field schemas. If a field -// exists in the old FlatSchema but not the new an empty diff mapping and an error is returned. -func CalculateFlatSchemaDiff(o, n FlatSchema) (map[string]FieldDiff, error) { - diffMap := map[string]FieldDiff{} - for field, schema := range o { - if _, ok := n[field]; !ok { - return diffMap, fmt.Errorf("field %q in existing not found in new", field) - } - newSchema := n[field] - - // Copy the schemas and remove any child properties for comparison. - // In theory this will focus in on detecting changes for only the - // field we are looking at and ignore changes in the children fields. - // Since we are iterating through the map that should have all fields - // we should still detect changes in the children fields. - oldCopy := schema.DeepCopy() - newCopy := newSchema.DeepCopy() - oldCopy.Properties, oldCopy.Items = nil, nil - newCopy.Properties, newCopy.Items = nil, nil - if !reflect.DeepEqual(oldCopy, newCopy) { - diffMap[field] = FieldDiff{ - Old: oldCopy, - New: newCopy, - } - } - } - return diffMap, nil -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator_test.go deleted file mode 100644 index cc12bc5c1..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/change_validator_test.go +++ /dev/null @@ -1,338 +0,0 @@ -// Originally copied from https://github.com/carvel-dev/kapp/tree/d7fc2e15439331aa3a379485bb124e91a0829d2e -// Attribution: -// Copyright 2024 The Carvel Authors. -// SPDX-License-Identifier: Apache-2.0 - -package crdupgradesafety_test - -import ( - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" -) - -func TestCalculateFlatSchemaDiff(t *testing.T) { - for _, tc := range []struct { - name string - old crdupgradesafety.FlatSchema - new crdupgradesafety.FlatSchema - expectedDiff map[string]crdupgradesafety.FieldDiff - shouldError bool - }{ - { - name: "no diff in schemas, empty diff, no error", - old: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{}, - }, - new: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{}, - }, - expectedDiff: map[string]crdupgradesafety.FieldDiff{}, - }, - { - name: "diff in schemas, diff returned, no error", - old: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{}, - }, - new: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - expectedDiff: map[string]crdupgradesafety.FieldDiff{ - "foo": { - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ID: "bar"}, - }, - }, - }, - { - name: "diff in child properties only, no diff returned, no error", - old: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{ - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "bar": {ID: "bar"}, - }, - }, - }, - new: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{ - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "bar": {ID: "baz"}, - }, - }, - }, - expectedDiff: map[string]crdupgradesafety.FieldDiff{}, - }, - { - name: "diff in child items only, no diff returned, no error", - old: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{ - Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &apiextensionsv1.JSONSchemaProps{ID: "bar"}}, - }, - }, - new: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{ - Items: &apiextensionsv1.JSONSchemaPropsOrArray{Schema: &apiextensionsv1.JSONSchemaProps{ID: "baz"}}, - }, - }, - expectedDiff: map[string]crdupgradesafety.FieldDiff{}, - }, - { - name: "field exists in old but not new, no diff returned, error", - old: crdupgradesafety.FlatSchema{ - "foo": &apiextensionsv1.JSONSchemaProps{}, - }, - new: crdupgradesafety.FlatSchema{ - "bar": &apiextensionsv1.JSONSchemaProps{}, - }, - expectedDiff: map[string]crdupgradesafety.FieldDiff{}, - shouldError: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - diff, err := crdupgradesafety.CalculateFlatSchemaDiff(tc.old, tc.new) - assert.Equal(t, tc.shouldError, err != nil, "should error? - %v", tc.shouldError) - assert.Equal(t, tc.expectedDiff, diff) - }) - } -} - -func TestFlattenSchema(t *testing.T) { - schema := &apiextensionsv1.JSONSchemaProps{ - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "foo": { - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "bar": {}, - }, - }, - "baz": {}, - }, - } - - foo := schema.Properties["foo"] - foobar := schema.Properties["foo"].Properties["bar"] - baz := schema.Properties["baz"] - expected := crdupgradesafety.FlatSchema{ - "^": schema, - "^.foo": &foo, - "^.foo.bar": &foobar, - "^.baz": &baz, - } - - actual := crdupgradesafety.FlattenSchema(schema) - - assert.Equal(t, expected, actual) -} - -func TestChangeValidator(t *testing.T) { - validationErr1 := errors.New(`version "v1alpha1", field "^" has unknown change, refusing to determine that change is safe`) - validationErr2 := errors.New(`version "v1alpha1", field "^": fail`) - - for _, tc := range []struct { - name string - changeValidator *crdupgradesafety.ChangeValidator - old apiextensionsv1.CustomResourceDefinition - new apiextensionsv1.CustomResourceDefinition - expectedError error - }{ - { - name: "no changes, no error", - changeValidator: &crdupgradesafety.ChangeValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("should not run") - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - }, - { - name: "changes, validation successful, change is fully handled, no error", - changeValidator: &crdupgradesafety.ChangeValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return true, nil - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - }, - { - name: "changes, validation successful, change not fully handled, error", - changeValidator: &crdupgradesafety.ChangeValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, nil - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: validationErr1, - }, - { - name: "changes, validation failed, change fully handled, error", - changeValidator: &crdupgradesafety.ChangeValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return true, errors.New("fail") - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: validationErr2, - }, - { - name: "changes, validation failed, change not fully handled, ordered error", - changeValidator: &crdupgradesafety.ChangeValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("fail") - }, - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("error") - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: fmt.Errorf("%w\n%s\n%w", validationErr2, `version "v1alpha1", field "^": error`, validationErr1), - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := tc.changeValidator.Validate(tc.old, tc.new) - if tc.expectedError != nil { - assert.EqualError(t, err, tc.expectedError.Error()) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks.go deleted file mode 100644 index 61d8b55c3..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks.go +++ /dev/null @@ -1,254 +0,0 @@ -package crdupgradesafety - -import ( - "bytes" - "cmp" - "fmt" - "reflect" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" -) - -type resetFunc func(diff FieldDiff) FieldDiff - -func isHandled(diff FieldDiff, reset resetFunc) bool { - diff = reset(diff) - return reflect.DeepEqual(diff.Old, diff.New) -} - -func Enum(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Enum = []apiextensionsv1.JSON{} - diff.New.Enum = []apiextensionsv1.JSON{} - return diff - } - - oldEnums := sets.New[string]() - for _, json := range diff.Old.Enum { - oldEnums.Insert(string(json.Raw)) - } - - newEnums := sets.New[string]() - for _, json := range diff.New.Enum { - newEnums.Insert(string(json.Raw)) - } - diffEnums := oldEnums.Difference(newEnums) - var err error - - switch { - case oldEnums.Len() == 0 && newEnums.Len() > 0: - err = fmt.Errorf("enum constraints %v added when there were no restrictions previously", newEnums.UnsortedList()) - case diffEnums.Len() > 0: - err = fmt.Errorf("enums %v removed from the set of previously allowed values", diffEnums.UnsortedList()) - } - - return isHandled(diff, reset), err -} - -func Required(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Required = []string{} - diff.New.Required = []string{} - return diff - } - - oldRequired := sets.New(diff.Old.Required...) - newRequired := sets.New(diff.New.Required...) - diffRequired := newRequired.Difference(oldRequired) - var err error - - if diffRequired.Len() > 0 { - err = fmt.Errorf("new required fields %v added", diffRequired.UnsortedList()) - } - - return isHandled(diff, reset), err -} - -func maxVerification[T cmp.Ordered](older *T, newer *T) error { - var err error - switch { - case older == nil && newer != nil: - err = fmt.Errorf("constraint %v added when there were no restrictions previously", *newer) - case older != nil && newer != nil && *newer < *older: - err = fmt.Errorf("constraint decreased from %v to %v", *older, *newer) - } - return err -} - -func Maximum(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Maximum = nil - diff.New.Maximum = nil - return diff - } - - err := maxVerification(diff.Old.Maximum, diff.New.Maximum) - if err != nil { - err = fmt.Errorf("maximum: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MaxItems(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MaxItems = nil - diff.New.MaxItems = nil - return diff - } - - err := maxVerification(diff.Old.MaxItems, diff.New.MaxItems) - if err != nil { - err = fmt.Errorf("maxItems: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MaxLength(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MaxLength = nil - diff.New.MaxLength = nil - return diff - } - - err := maxVerification(diff.Old.MaxLength, diff.New.MaxLength) - if err != nil { - err = fmt.Errorf("maxLength: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MaxProperties(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MaxProperties = nil - diff.New.MaxProperties = nil - return diff - } - - err := maxVerification(diff.Old.MaxProperties, diff.New.MaxProperties) - if err != nil { - err = fmt.Errorf("maxProperties: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func minVerification[T cmp.Ordered](older *T, newer *T) error { - var err error - switch { - case older == nil && newer != nil: - err = fmt.Errorf("constraint %v added when there were no restrictions previously", *newer) - case older != nil && newer != nil && *newer > *older: - err = fmt.Errorf("constraint increased from %v to %v", *older, *newer) - } - return err -} - -func Minimum(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Minimum = nil - diff.New.Minimum = nil - return diff - } - - err := minVerification(diff.Old.Minimum, diff.New.Minimum) - if err != nil { - err = fmt.Errorf("minimum: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MinItems(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MinItems = nil - diff.New.MinItems = nil - return diff - } - - err := minVerification(diff.Old.MinItems, diff.New.MinItems) - if err != nil { - err = fmt.Errorf("minItems: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MinLength(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MinLength = nil - diff.New.MinLength = nil - return diff - } - - err := minVerification(diff.Old.MinLength, diff.New.MinLength) - if err != nil { - err = fmt.Errorf("minLength: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func MinProperties(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.MinProperties = nil - diff.New.MinProperties = nil - return diff - } - - err := minVerification(diff.Old.MinProperties, diff.New.MinProperties) - if err != nil { - err = fmt.Errorf("minProperties: %s", err.Error()) - } - - return isHandled(diff, reset), err -} - -func Default(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Default = nil - diff.New.Default = nil - return diff - } - - var err error - - switch { - case diff.Old.Default == nil && diff.New.Default != nil: - err = fmt.Errorf("default value %q added when there was no default previously", string(diff.New.Default.Raw)) - case diff.Old.Default != nil && diff.New.Default == nil: - err = fmt.Errorf("default value %q removed", string(diff.Old.Default.Raw)) - case diff.Old.Default != nil && diff.New.Default != nil && !bytes.Equal(diff.Old.Default.Raw, diff.New.Default.Raw): - err = fmt.Errorf("default value changed from %q to %q", string(diff.Old.Default.Raw), string(diff.New.Default.Raw)) - } - - return isHandled(diff, reset), err -} - -func Type(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Type = "" - diff.New.Type = "" - return diff - } - - var err error - if diff.Old.Type != diff.New.Type { - err = fmt.Errorf("type changed from %q to %q", diff.Old.Type, diff.New.Type) - } - - return isHandled(diff, reset), err -} - -// Description changes are considered safe and non-breaking. -func Description(diff FieldDiff) (bool, error) { - reset := func(diff FieldDiff) FieldDiff { - diff.Old.Description = "" - diff.New.Description = "" - return diff - } - return isHandled(diff, reset), nil -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks_test.go deleted file mode 100644 index ebceed8b4..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/checks_test.go +++ /dev/null @@ -1,1008 +0,0 @@ -package crdupgradesafety - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/require" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/utils/ptr" -) - -type testcase struct { - name string - diff FieldDiff - err error - handled bool -} - -func TestEnum(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - }, - }, - }, - err: nil, - handled: true, - }, - { - name: "new enum constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{}, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - }, - }, - }, - err: errors.New("enum constraints [foo] added when there were no restrictions previously"), - handled: true, - }, - { - name: "remove enum value, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - { - Raw: []byte("bar"), - }, - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("bar"), - }, - }, - }, - }, - err: errors.New("enums [foo] removed from the set of previously allowed values"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - { - name: "different field changed with enum, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - Enum: []apiextensionsv1.JSON{ - { - Raw: []byte("foo"), - }, - }, - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Enum(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestRequired(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Required: []string{ - "foo", - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Required: []string{ - "foo", - }, - }, - }, - err: nil, - handled: true, - }, - { - name: "new required field, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - Required: []string{ - "foo", - }, - }, - }, - err: errors.New("new required fields [foo] added"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Required(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMaximum(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(10.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(10.0), - }, - }, - err: nil, - handled: true, - }, - { - name: "new maximum constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(10.0), - }, - }, - err: errors.New("maximum: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "maximum constraint decreased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(20.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(10.0), - }, - }, - err: errors.New("maximum: constraint decreased from 20 to 10"), - handled: true, - }, - { - name: "maximum constraint increased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(20.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Maximum: ptr.To(30.0), - }, - }, - err: nil, - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Maximum(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMaxItems(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new maxItems constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(10)), - }, - }, - err: errors.New("maxItems: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "maxItems constraint decreased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(10)), - }, - }, - err: errors.New("maxItems: constraint decreased from 20 to 10"), - handled: true, - }, - { - name: "maxitems constraint increased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxItems: ptr.To(int64(20)), - }, - }, - err: nil, - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MaxItems(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMaxLength(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new maxLength constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(10)), - }, - }, - err: errors.New("maxLength: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "maxLength constraint decreased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(10)), - }, - }, - err: errors.New("maxLength: constraint decreased from 20 to 10"), - handled: true, - }, - { - name: "maxLength constraint increased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxLength: ptr.To(int64(20)), - }, - }, - err: nil, - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MaxLength(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMaxProperties(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new maxProperties constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(10)), - }, - }, - err: errors.New("maxProperties: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "maxProperties constraint decreased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(10)), - }, - }, - err: errors.New("maxProperties: constraint decreased from 20 to 10"), - handled: true, - }, - { - name: "maxProperties constraint increased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MaxProperties: ptr.To(int64(20)), - }, - }, - err: nil, - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MaxProperties(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMinItems(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new minItems constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(10)), - }, - }, - err: errors.New("minItems: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "minItems constraint decreased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "minItems constraint increased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinItems: ptr.To(int64(20)), - }, - }, - err: errors.New("minItems: constraint increased from 10 to 20"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MinItems(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMinimum(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(10.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(10.0), - }, - }, - err: nil, - handled: true, - }, - { - name: "new minimum constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(10.0), - }, - }, - err: errors.New("minimum: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "minLength constraint decreased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(20.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(10.0), - }, - }, - err: nil, - handled: true, - }, - { - name: "minLength constraint increased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(10.0), - }, - New: &apiextensionsv1.JSONSchemaProps{ - Minimum: ptr.To(20.0), - }, - }, - err: errors.New("minimum: constraint increased from 10 to 20"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Minimum(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMinLength(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new minLength constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(10)), - }, - }, - err: errors.New("minLength: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "minLength constraint decreased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "minLength constraint increased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinLength: ptr.To(int64(20)), - }, - }, - err: errors.New("minLength: constraint increased from 10 to 20"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MinLength(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestMinProperties(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "new minProperties constraint, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(10)), - }, - }, - err: errors.New("minProperties: constraint 10 added when there were no restrictions previously"), - handled: true, - }, - { - name: "minProperties constraint decreased, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(20)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(10)), - }, - }, - err: nil, - handled: true, - }, - { - name: "minProperties constraint increased, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(10)), - }, - New: &apiextensionsv1.JSONSchemaProps{ - MinProperties: ptr.To(int64(20)), - }, - }, - err: errors.New("minProperties: constraint increased from 10 to 20"), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := MinProperties(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestDefault(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("foo"), - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("foo"), - }, - }, - }, - err: nil, - handled: true, - }, - { - name: "new default value, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("foo"), - }, - }, - }, - err: errors.New("default value \"foo\" added when there was no default previously"), - handled: true, - }, - { - name: "default value removed, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("foo"), - }, - }, - New: &apiextensionsv1.JSONSchemaProps{}, - }, - err: errors.New("default value \"foo\" removed"), - handled: true, - }, - { - name: "default value changed, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("foo"), - }, - }, - New: &apiextensionsv1.JSONSchemaProps{ - Default: &apiextensionsv1.JSON{ - Raw: []byte("bar"), - }, - }, - }, - err: errors.New("default value changed from \"foo\" to \"bar\""), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Default(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestType(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Type: "string", - }, - New: &apiextensionsv1.JSONSchemaProps{ - Type: "string", - }, - }, - err: nil, - handled: true, - }, - { - name: "type changed, error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Type: "string", - }, - New: &apiextensionsv1.JSONSchemaProps{ - Type: "integer", - }, - }, - err: errors.New("type changed from \"string\" to \"integer\""), - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Type(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} - -func TestDescription(t *testing.T) { - for _, tc := range []testcase{ - { - name: "no diff, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Description: "some field", - }, - New: &apiextensionsv1.JSONSchemaProps{ - Description: "some field", - }, - }, - err: nil, - handled: true, - }, - { - name: "description changed, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Description: "old description", - }, - New: &apiextensionsv1.JSONSchemaProps{ - Description: "new description", - }, - }, - err: nil, - handled: true, - }, - { - name: "description added, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{}, - New: &apiextensionsv1.JSONSchemaProps{ - Description: "a new description was added", - }, - }, - err: nil, - handled: true, - }, - { - name: "description removed, no error, handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - Description: "this description will be removed", - }, - New: &apiextensionsv1.JSONSchemaProps{}, - }, - err: nil, - handled: true, - }, - { - name: "different field changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - }, - }, - err: nil, - handled: false, - }, - { - name: "different field changed with description, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - Description: "description", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - Description: "description", - }, - }, - err: nil, - handled: false, - }, - { - name: "description and ID changed, no error, not handled", - diff: FieldDiff{ - Old: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - Description: "old description", - }, - New: &apiextensionsv1.JSONSchemaProps{ - ID: "bar", - Description: "new description", - }, - }, - err: nil, - handled: false, - }, - } { - t.Run(tc.name, func(t *testing.T) { - handled, err := Description(tc.diff) - require.Equal(t, tc.err, err) - require.Equal(t, tc.handled, handled) - }) - } -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go index 0904bf4d4..d317d1f13 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go @@ -13,50 +13,39 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + crdifyconfig "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + crdifyrunner "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/runner" + crdifyvalidations "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + crdifyproperty "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/property" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) type Option func(p *Preflight) -func WithValidator(v *Validator) Option { +func WithConfig(cfg *crdifyconfig.Config) Option { return func(p *Preflight) { - p.validator = v + p.config = cfg + } +} + +func WithRegistry(reg crdifyvalidations.Registry) Option { + return func(p *Preflight) { + p.registry = reg } } type Preflight struct { crdClient apiextensionsv1client.CustomResourceDefinitionInterface - validator *Validator + config *crdifyconfig.Config + registry crdifyvalidations.Registry } func NewPreflight(crdCli apiextensionsv1client.CustomResourceDefinitionInterface, opts ...Option) *Preflight { - changeValidations := []ChangeValidation{ - Description, - Enum, - Required, - Maximum, - MaxItems, - MaxLength, - MaxProperties, - Minimum, - MinItems, - MinLength, - MinProperties, - Default, - Type, - } p := &Preflight{ crdClient: crdCli, - // create a default validator. Can be overridden via the options - validator: &Validator{ - Validations: []Validation{ - NewValidationFunc("NoScopeChange", NoScopeChange), - NewValidationFunc("NoStoredVersionRemoved", NoStoredVersionRemoved), - NewValidationFunc("NoExistingFieldRemoved", NoExistingFieldRemoved), - &ServedVersionValidator{Validations: changeValidations}, - &ChangeValidator{Validations: changeValidations}, - }, - }, + config: defaultConfig(), + registry: defaultRegistry(), } for _, o := range opts { @@ -84,6 +73,11 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro return fmt.Errorf("parsing release %q objects: %w", rel.Name, err) } + runner, err := crdifyrunner.New(p.config, p.registry) + if err != nil { + return fmt.Errorf("creating CRD validation runner: %w", err) + } + validateErrors := make([]error, 0, len(relObjects)) for _, obj := range relObjects { if obj.GetObjectKind().GroupVersionKind() != apiextensionsv1.SchemeGroupVersion.WithKind("CustomResourceDefinition") { @@ -110,11 +104,237 @@ func (p *Preflight) runPreflight(ctx context.Context, rel *release.Release) erro return fmt.Errorf("getting existing resource for CRD %q: %w", newCrd.Name, err) } - err = p.validator.Validate(*oldCrd, *newCrd) - if err != nil { - validateErrors = append(validateErrors, fmt.Errorf("validating upgrade for CRD %q failed: %w", newCrd.Name, err)) + results := runner.Run(oldCrd, newCrd) + if results.HasFailures() { + resultErrs := crdWideErrors(results) + resultErrs = append(resultErrs, sameVersionErrors(results)...) + resultErrs = append(resultErrs, servedVersionErrors(results)...) + if len(resultErrs) > 0 { + validateErrors = append(validateErrors, fmt.Errorf("validating upgrade for CRD %q: %w", newCrd.Name, errors.Join(resultErrs...))) + } } } return errors.Join(validateErrors...) } + +func defaultConfig() *crdifyconfig.Config { + return &crdifyconfig.Config{ + // Ignore served version validations if conversion policy is set. + Conversion: crdifyconfig.ConversionPolicyIgnore, + // Fail-closed by default + UnhandledEnforcement: crdifyconfig.EnforcementPolicyError, + // Use the default validation configurations as they are + // the strictest possible. + Validations: []crdifyconfig.ValidationConfig{ + // Do not enforce the description validation + // because OLM should not block on field description changes. + { + Name: "description", + Enforcement: crdifyconfig.EnforcementPolicyNone, + }, + { + Name: "enum", + Enforcement: crdifyconfig.EnforcementPolicyError, + Configuration: map[string]interface{}{ + "additionPolicy": crdifyproperty.AdditionPolicyAllow, + }, + }, + }, + } +} + +func defaultRegistry() crdifyvalidations.Registry { + return crdifyrunner.DefaultRegistry() +} + +func crdWideErrors(results *crdifyrunner.Results) []error { + if results == nil { + return nil + } + + errs := []error{} + for _, result := range results.CRDValidation { + for _, err := range result.Errors { + errs = append(errs, fmt.Errorf("%s: %s", result.Name, err)) + } + } + + return errs +} + +func sameVersionErrors(results *crdifyrunner.Results) []error { + if results == nil { + return nil + } + + errs := []error{} + for version, propertyResults := range results.SameVersionValidation { + for property, comparisonResults := range propertyResults { + for _, result := range comparisonResults { + for _, err := range result.Errors { + msg := err + if result.Name == "unhandled" { + msg = conciseUnhandledMessage(err) + } + errs = append(errs, fmt.Errorf("%s: %s: %s: %s", version, property, result.Name, msg)) + } + } + } + } + + return errs +} + +func servedVersionErrors(results *crdifyrunner.Results) []error { + if results == nil { + return nil + } + + errs := []error{} + for version, propertyResults := range results.ServedVersionValidation { + for property, comparisonResults := range propertyResults { + for _, result := range comparisonResults { + for _, err := range result.Errors { + msg := err + if result.Name == "unhandled" { + msg = conciseUnhandledMessage(err) + } + errs = append(errs, fmt.Errorf("%s: %s: %s: %s", version, property, result.Name, msg)) + } + } + } + } + + return errs +} + +const unhandledSummaryPrefix = "unhandled changes found" + +// conciseUnhandledMessage trims the CRD diff emitted by crdify's "unhandled" comparator +// into a short human readable description so operators get a hint of the change without +// the unreadable Go struct dump. +func conciseUnhandledMessage(raw string) string { + if !strings.Contains(raw, unhandledSummaryPrefix) { + return raw + } + + details := extractUnhandledDetails(raw) + if len(details) == 0 { + return unhandledSummaryPrefix + } + + return fmt.Sprintf("%s (%s)", unhandledSummaryPrefix, strings.Join(details, "; ")) +} + +func extractUnhandledDetails(raw string) []string { + type diffEntry struct { + before string + after string + beforeRaw string + afterRaw string + } + + entries := map[string]*diffEntry{} + order := []string{} + + for _, line := range strings.Split(raw, "\n") { + trimmed := strings.TrimSpace(line) + if len(trimmed) < 2 { + continue + } + + sign := trimmed[0] + if sign != '-' && sign != '+' { + continue + } + + field, value, rawValue := parseUnhandledDiffValue(trimmed[1:]) + if field == "" { + continue + } + + entry, ok := entries[field] + if !ok { + entry = &diffEntry{} + entries[field] = entry + order = append(order, field) + } + + if sign == '-' { + entry.before = value + entry.beforeRaw = rawValue + } else { + entry.after = value + entry.afterRaw = rawValue + } + } + + details := []string{} + for _, field := range order { + entry := entries[field] + if entry.before == "" && entry.after == "" { + continue + } + if entry.before == entry.after && entry.beforeRaw == entry.afterRaw { + continue + } + + before := entry.before + if before == "" { + before = "" + } + after := entry.after + if after == "" { + after = "" + } + if entry.before == entry.after && entry.beforeRaw != entry.afterRaw { + after = after + " (changed)" + } + + details = append(details, fmt.Sprintf("%s %s -> %s", field, before, after)) + } + + return details +} + +func parseUnhandledDiffValue(fragment string) (string, string, string) { + cleaned := strings.TrimSpace(fragment) + cleaned = strings.TrimPrefix(cleaned, "\t") + cleaned = strings.TrimSpace(cleaned) + cleaned = strings.TrimSuffix(cleaned, ",") + + parts := strings.SplitN(cleaned, ":", 2) + if len(parts) != 2 { + return "", "", "" + } + + field := strings.TrimSpace(parts[0]) + rawValue := strings.TrimSpace(parts[1]) + value := normalizeUnhandledValue(rawValue) + + if field == "" { + return "", "", "" + } + + return field, value, rawValue +} + +func normalizeUnhandledValue(value string) string { + value = strings.TrimSuffix(value, ",") + value = strings.TrimSpace(value) + + switch value { + case "": + return "" + case "\"\"": + return "\"\"" + } + + value = strings.ReplaceAll(value, "v1.", "") + if strings.Contains(value, "JSONSchemaProps") { + return "" + } + + return value +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go index 12241bd7f..4ed62e9e0 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go @@ -16,6 +16,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" + crdifyconfig "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) @@ -30,18 +32,15 @@ func (c *MockCRDGetter) Get(ctx context.Context, name string, options metav1.Get return c.oldCrd, c.getErr } -func newMockPreflight(crd *apiextensionsv1.CustomResourceDefinition, err error, customValidator *crdupgradesafety.Validator) *crdupgradesafety.Preflight { +func newMockPreflight(crd *apiextensionsv1.CustomResourceDefinition, err error) *crdupgradesafety.Preflight { var preflightOpts []crdupgradesafety.Option - if customValidator != nil { - preflightOpts = append(preflightOpts, crdupgradesafety.WithValidator(customValidator)) - } return crdupgradesafety.NewPreflight(&MockCRDGetter{ oldCrd: crd, getErr: err, }, preflightOpts...) } -const crdFolder string = "../../../../../testdata/manifests" +const crdFolder string = "testdata/manifests" func getCrdFromManifestFile(t *testing.T, oldCrdFile string) *apiextensionsv1.CustomResourceDefinition { if oldCrdFile == "" { @@ -69,15 +68,22 @@ func getManifestString(t *testing.T, crdFile string) string { return string(buff) } +func wantErrorMsgs(wantMsgs []string) require.ErrorAssertionFunc { + return func(t require.TestingT, haveErr error, _ ...interface{}) { + for _, wantMsg := range wantMsgs { + require.ErrorContains(t, haveErr, wantMsg) + } + } +} + // TestInstall exists only for completeness as Install() is currently a no-op. It can be used as // a template for real tests in the future if the func is implemented. func TestInstall(t *testing.T) { tests := []struct { name string oldCrdPath string - validator *crdupgradesafety.Validator release *release.Release - wantErrMsgs []string + requireErr require.ErrorAssertionFunc wantCrdGetErr error }{ { @@ -95,7 +101,7 @@ func TestInstall(t *testing.T) { Name: "test-release", Manifest: "abcd", }, - wantErrMsgs: []string{"json: cannot unmarshal string into Go value of type unstructured.detector"}, + requireErr: wantErrorMsgs([]string{"json: cannot unmarshal string into Go value of type unstructured.detector"}), }, { name: "release with no CRD objects", @@ -111,7 +117,7 @@ func TestInstall(t *testing.T) { Manifest: getManifestString(t, "crd-valid-upgrade.json"), }, wantCrdGetErr: fmt.Errorf("error!"), - wantErrMsgs: []string{"error!"}, + requireErr: wantErrorMsgs([]string{"error!"}), }, { name: "fail to get old crd, not found error", @@ -127,23 +133,7 @@ func TestInstall(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-invalid"), }, - wantErrMsgs: []string{"json: cannot unmarshal"}, - }, - { - name: "custom validator", - oldCrdPath: "old-crd.json", - release: &release.Release{ - Name: "test-release", - Manifest: getManifestString(t, "old-crd.json"), - }, - validator: &crdupgradesafety.Validator{ - Validations: []crdupgradesafety.Validation{ - crdupgradesafety.NewValidationFunc("test", func(old, new apiextensionsv1.CustomResourceDefinition) error { - return fmt.Errorf("custom validation error!!") - }), - }, - }, - wantErrMsgs: []string{"custom validation error!!"}, + requireErr: wantErrorMsgs([]string{"json: cannot unmarshal"}), }, { name: "valid upgrade", @@ -162,21 +152,21 @@ func TestInstall(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-invalid-upgrade.json"), }, - wantErrMsgs: []string{ - `"NoScopeChange"`, - `"NoStoredVersionRemoved"`, - `enum constraints`, - `new required fields`, - `maximum: constraint`, - `maxItems: constraint`, - `maxLength: constraint`, - `maxProperties: constraint`, - `minimum: constraint`, - `minItems: constraint`, - `minLength: constraint`, - `minProperties: constraint`, - `default value`, - }, + requireErr: wantErrorMsgs([]string{ + `scope:`, + `storedVersionRemoval:`, + `enum:`, + `required:`, + `maximum:`, + `maxItems:`, + `maxLength:`, + `maxProperties:`, + `minimum:`, + `minItems:`, + `minLength:`, + `minProperties:`, + `default:`, + }), }, { name: "new crd validation failure for existing field removal", @@ -187,20 +177,28 @@ func TestInstall(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-field-removed.json"), }, - wantErrMsgs: []string{ - `"NoExistingFieldRemoved"`, + requireErr: wantErrorMsgs([]string{ + `existingFieldRemoval:`, + }), + }, + { + name: "new crd validation should not fail on description changes", + // Separate test from above as this error will cause the validator to + // return early and skip some of the above validations. + oldCrdPath: "old-crd.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-description-changed.json"), }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr, tc.validator) + preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr) err := preflight.Install(context.Background(), tc.release) - if len(tc.wantErrMsgs) != 0 { - for _, expectedErrMsg := range tc.wantErrMsgs { - require.ErrorContainsf(t, err, expectedErrMsg, "") - } + if tc.requireErr != nil { + tc.requireErr(t, err) } else { require.NoError(t, err) } @@ -212,9 +210,8 @@ func TestUpgrade(t *testing.T) { tests := []struct { name string oldCrdPath string - validator *crdupgradesafety.Validator release *release.Release - wantErrMsgs []string + requireErr require.ErrorAssertionFunc wantCrdGetErr error }{ { @@ -232,7 +229,7 @@ func TestUpgrade(t *testing.T) { Name: "test-release", Manifest: "abcd", }, - wantErrMsgs: []string{"json: cannot unmarshal string into Go value of type unstructured.detector"}, + requireErr: wantErrorMsgs([]string{"json: cannot unmarshal string into Go value of type unstructured.detector"}), }, { name: "release with no CRD objects", @@ -248,7 +245,7 @@ func TestUpgrade(t *testing.T) { Manifest: getManifestString(t, "crd-valid-upgrade.json"), }, wantCrdGetErr: fmt.Errorf("error!"), - wantErrMsgs: []string{"error!"}, + requireErr: wantErrorMsgs([]string{"error!"}), }, { name: "fail to get old crd, not found error", @@ -264,23 +261,7 @@ func TestUpgrade(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-invalid"), }, - wantErrMsgs: []string{"json: cannot unmarshal"}, - }, - { - name: "custom validator", - oldCrdPath: "old-crd.json", - release: &release.Release{ - Name: "test-release", - Manifest: getManifestString(t, "old-crd.json"), - }, - validator: &crdupgradesafety.Validator{ - Validations: []crdupgradesafety.Validation{ - crdupgradesafety.NewValidationFunc("test", func(old, new apiextensionsv1.CustomResourceDefinition) error { - return fmt.Errorf("custom validation error!!") - }), - }, - }, - wantErrMsgs: []string{"custom validation error!!"}, + requireErr: wantErrorMsgs([]string{"json: cannot unmarshal"}), }, { name: "valid upgrade", @@ -299,21 +280,21 @@ func TestUpgrade(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-invalid-upgrade.json"), }, - wantErrMsgs: []string{ - `"NoScopeChange"`, - `"NoStoredVersionRemoved"`, - `enum constraints`, - `new required fields`, - `maximum: constraint`, - `maxItems: constraint`, - `maxLength: constraint`, - `maxProperties: constraint`, - `minimum: constraint`, - `minItems: constraint`, - `minLength: constraint`, - `minProperties: constraint`, - `default value`, - }, + requireErr: wantErrorMsgs([]string{ + `scope:`, + `storedVersionRemoval:`, + `enum:`, + `required:`, + `maximum:`, + `maxItems:`, + `maxLength:`, + `maxProperties:`, + `minimum:`, + `minItems:`, + `minLength:`, + `minProperties:`, + `default:`, + }), }, { name: "new crd validation failure for existing field removal", @@ -324,9 +305,9 @@ func TestUpgrade(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-field-removed.json"), }, - wantErrMsgs: []string{ - `"NoExistingFieldRemoved"`, - }, + requireErr: wantErrorMsgs([]string{ + `existingFieldRemoval:`, + }), }, { name: "webhook conversion strategy exists", @@ -343,23 +324,95 @@ func TestUpgrade(t *testing.T) { Name: "test-release", Manifest: getManifestString(t, "crd-conversion-no-webhook.json"), }, - wantErrMsgs: []string{ - `"ServedVersionValidator" validation failed: version upgrade "v1" to "v2", field "^.spec.foobarbaz": enums`, + requireErr: wantErrorMsgs([]string{ + `validating upgrade for CRD "crontabs.stable.example.com": v1 -> v2: ^.spec.foobarbaz: enum: allowed enum values removed`, + }), + }, + { + name: "new crd validation should not fail on description changes", + // Separate test from above as this error will cause the validator to + // return early and skip some of the above validations. + oldCrdPath: "old-crd.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-description-changed.json"), + }, + }, + { + name: "success when old crd and new crd contain the exact same validation issues", + oldCrdPath: "crd-conversion-no-webhook.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-conversion-no-webhook.json"), + }, + }, + { + name: "failure when old crd and new crd contain the exact same validation issues, but new crd introduces another validation issue", + oldCrdPath: "crd-conversion-no-webhook.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-conversion-no-webhook-extra-issue.json"), + }, + requireErr: func(t require.TestingT, err error, _ ...interface{}) { + require.ErrorContains(t, err, + `validating upgrade for CRD "crontabs.stable.example.com":`, + ) + // The newly introduced issue is reported + require.Contains(t, err.Error(), + `v1 -> v2: ^.spec.extraField: type: type changed : "boolean" -> "string"`, + ) + // The existing issue is not reported + require.NotContains(t, err.Error(), + `v1 -> v2: ^.spec.foobarbaz: enum: allowed enum values removed`, + ) }, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr, tc.validator) + preflight := newMockPreflight(getCrdFromManifestFile(t, tc.oldCrdPath), tc.wantCrdGetErr) err := preflight.Upgrade(context.Background(), tc.release) - if len(tc.wantErrMsgs) != 0 { - for _, expectedErrMsg := range tc.wantErrMsgs { - require.ErrorContainsf(t, err, expectedErrMsg, "") - } + if tc.requireErr != nil { + tc.requireErr(t, err) } else { require.NoError(t, err) } }) } } + +func TestUpgrade_UnhandledChanges_InSpec_DefaultPolicy(t *testing.T) { + t.Run("unhandled spec changes cause error by default", func(t *testing.T) { + preflight := newMockPreflight(getCrdFromManifestFile(t, "crd-unhandled-old.json"), nil) + rel := &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-unhandled-new.json"), + } + err := preflight.Upgrade(context.Background(), rel) + require.Error(t, err) + require.ErrorContains(t, err, "unhandled changes found") + require.ErrorContains(t, err, "Format \"\" -> \"email\"") + require.NotContains(t, err.Error(), "v1.JSONSchemaProps", "error message should be concise without raw diff") + }) +} + +func TestUpgrade_UnhandledChanges_PolicyError(t *testing.T) { + t.Run("unhandled changes error when policy is Error", func(t *testing.T) { + oldCrd := getCrdFromManifestFile(t, "crd-unhandled-old.json") + preflight := crdupgradesafety.NewPreflight(&MockCRDGetter{oldCrd: oldCrd}, crdupgradesafety.WithConfig(&crdifyconfig.Config{ + Conversion: crdifyconfig.ConversionPolicyIgnore, + UnhandledEnforcement: crdifyconfig.EnforcementPolicyError, + })) + + rel := &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-unhandled-new.json"), + } + + err := preflight.Upgrade(context.Background(), rel) + require.Error(t, err) + require.ErrorContains(t, err, "unhandled changes found") + require.ErrorContains(t, err, "Format \"\" -> \"email\"") + }) +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator.go deleted file mode 100644 index d66f1ed9c..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator.go +++ /dev/null @@ -1,74 +0,0 @@ -package crdupgradesafety - -import ( - "errors" - "fmt" - "maps" - "slices" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - versionhelper "k8s.io/apimachinery/pkg/version" -) - -type ServedVersionValidator struct { - Validations []ChangeValidation -} - -func (c *ServedVersionValidator) Validate(old, new apiextensionsv1.CustomResourceDefinition) error { - // If conversion webhook is specified, pass check - if new.Spec.Conversion != nil && new.Spec.Conversion.Strategy == apiextensionsv1.WebhookConverter { - return nil - } - - errs := []error{} - servedVersions := []apiextensionsv1.CustomResourceDefinitionVersion{} - for _, version := range new.Spec.Versions { - if version.Served { - servedVersions = append(servedVersions, version) - } - } - - slices.SortFunc(servedVersions, func(a, b apiextensionsv1.CustomResourceDefinitionVersion) int { - return versionhelper.CompareKubeAwareVersionStrings(a.Name, b.Name) - }) - - for i, oldVersion := range servedVersions[:len(servedVersions)-1] { - for _, newVersion := range servedVersions[i+1:] { - flatOld := FlattenSchema(oldVersion.Schema.OpenAPIV3Schema) - flatNew := FlattenSchema(newVersion.Schema.OpenAPIV3Schema) - diffs, err := CalculateFlatSchemaDiff(flatOld, flatNew) - if err != nil { - errs = append(errs, fmt.Errorf("calculating schema diff between CRD versions %q and %q", oldVersion.Name, newVersion.Name)) - continue - } - - for _, field := range slices.Sorted(maps.Keys(diffs)) { - diff := diffs[field] - - handled := false - for _, validation := range c.Validations { - ok, err := validation(diff) - if err != nil { - errs = append(errs, fmt.Errorf("version upgrade %q to %q, field %q: %w", oldVersion.Name, newVersion.Name, field, err)) - } - if ok { - handled = true - break - } - } - - if !handled { - errs = append(errs, fmt.Errorf("version %q, field %q has unknown change, refusing to determine that change is safe", oldVersion.Name, field)) - } - } - } - } - if len(errs) > 0 { - return errors.Join(errs...) - } - return nil -} - -func (c *ServedVersionValidator) Name() string { - return "ServedVersionValidator" -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator_test.go deleted file mode 100644 index 67b0c6205..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/shared_version_validator_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package crdupgradesafety_test - -import ( - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/preflights/crdupgradesafety" -) - -func TestServedVersionValidator(t *testing.T) { - validationErr1 := errors.New(`version "v1alpha1", field "^" has unknown change, refusing to determine that change is safe`) - validationErr2 := errors.New(`version upgrade "v1alpha1" to "v1alpha2", field "^": fail`) - - for _, tc := range []struct { - name string - servedVersionValidator *crdupgradesafety.ServedVersionValidator - new apiextensionsv1.CustomResourceDefinition - expectedError error - }{ - { - name: "no changes, no error", - servedVersionValidator: &crdupgradesafety.ServedVersionValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("should not run") - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - }, - }, - }, - }, - { - name: "changes, validation successful, change is fully handled, no error", - servedVersionValidator: &crdupgradesafety.ServedVersionValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return true, nil - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - { - Name: "v1alpha2", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - }, - { - name: "changes, validation successful, change not fully handled, error", - servedVersionValidator: &crdupgradesafety.ServedVersionValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, nil - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - { - Name: "v1alpha2", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: validationErr1, - }, - { - name: "changes, validation failed, change fully handled, error", - servedVersionValidator: &crdupgradesafety.ServedVersionValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return true, errors.New("fail") - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - { - Name: "v1alpha2", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: validationErr2, - }, - { - name: "changes, validation failed, change not fully handled, ordered error", - servedVersionValidator: &crdupgradesafety.ServedVersionValidator{ - Validations: []crdupgradesafety.ChangeValidation{ - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("fail") - }, - func(_ crdupgradesafety.FieldDiff) (bool, error) { - return false, errors.New("error") - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{}, - }, - }, - { - Name: "v1alpha2", - Served: true, - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - ID: "foo", - }, - }, - }, - }, - }, - }, - expectedError: fmt.Errorf("%w\n%s\n%w", validationErr2, `version upgrade "v1alpha1" to "v1alpha2", field "^": error`, validationErr1), - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := tc.servedVersionValidator.Validate(apiextensionsv1.CustomResourceDefinition{}, tc.new) - if tc.expectedError != nil { - assert.EqualError(t, err, tc.expectedError.Error()) - } else { - assert.NoError(t, err) - } - }) - } -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-no-webhook-extra-issue.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-no-webhook-extra-issue.json new file mode 100644 index 000000000..0bfd13384 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-no-webhook-extra-issue.json @@ -0,0 +1,76 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "crontabs.stable.example.com" + }, + "spec": { + "group": "stable.example.com", + "versions": [ + { + "name": "v2", + "served": true, + "storage": false, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "foobarbaz": { + "type":"string", + "enum":[ + "bark", + "woof" + ] + }, + "extraField": { + "type":"string" + } + } + } + } + } + } + }, + { + "name": "v1", + "served": true, + "storage": false, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "foobarbaz": { + "type":"string", + "enum":[ + "foo", + "bar", + "baz" + ] + }, + "extraField": { + "type":"boolean" + } + } + } + } + } + } + } + ], + "scope": "Cluster", + "names": { + "plural": "crontabs", + "singular": "crontab", + "kind": "CronTab", + "shortNames": [ + "ct" + ] + } + } +} diff --git a/testdata/manifests/crd-conversion-no-webhook.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-no-webhook.json similarity index 100% rename from testdata/manifests/crd-conversion-no-webhook.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-no-webhook.json diff --git a/testdata/manifests/crd-conversion-webhook-old.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-webhook-old.json similarity index 100% rename from testdata/manifests/crd-conversion-webhook-old.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-webhook-old.json diff --git a/testdata/manifests/crd-conversion-webhook.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-webhook.json similarity index 100% rename from testdata/manifests/crd-conversion-webhook.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-conversion-webhook.json diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-description-changed.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-description-changed.json new file mode 100644 index 000000000..0e7f9a600 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-description-changed.json @@ -0,0 +1,124 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "crontabs.stable.example.com" + }, + "spec": { + "group": "stable.example.com", + "versions": [ + { + "name": "v1", + "served": true, + "storage": false, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "description": "description two", + "type": "object", + "properties": { + "removedField": { + "type":"integer" + }, + "enum": { + "type": "string", + "enum": ["a", "b", "c"] + }, + "minMaxValue": { + "type":"integer" + }, + "required": { + "type":"integer" + }, + "minMaxItems": { + "type":"array", + "items": { + "type":"string" + } + }, + "minMaxLength": { + "type":"string" + }, + "defaultVal": { + "type": "string" + }, + "requiredVal": { + "type": "string" + } + } + } + }, + "required": [ + "requiredVal" + ] + } + } + }, + { + "name": "v2", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "removedField": { + "type":"integer" + }, + "enum": { + "type": "string", + "enum": ["a", "b", "c"] + }, + "minMaxValue": { + "type":"integer" + }, + "required": { + "type":"integer" + }, + "minMaxItems": { + "type":"array", + "items": { + "type":"string" + } + }, + "minMaxLength": { + "type":"string" + }, + "defaultVal": { + "type": "string" + }, + "requiredVal": { + "type": "string" + } + } + } + }, + "required": [ + "requiredVal" + ] + } + } + } + ], + "scope": "Cluster", + "names": { + "plural": "crontabs", + "singular": "crontab", + "kind": "CronTab", + "shortNames": [ + "ct" + ] + } + }, + "status": { + "storedVersions": [ + "v1", + "v2" + ] + } +} diff --git a/testdata/manifests/crd-field-removed.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-field-removed.json similarity index 96% rename from testdata/manifests/crd-field-removed.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-field-removed.json index 86ba06e40..650b13fd4 100644 --- a/testdata/manifests/crd-field-removed.json +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-field-removed.json @@ -22,7 +22,8 @@ "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c"] }, "minMaxValue": { "type":"integer" @@ -66,7 +67,8 @@ "type": "object", "properties": { "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c"] }, "minMaxValue": { "type":"integer" diff --git a/testdata/manifests/crd-invalid b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-invalid similarity index 100% rename from testdata/manifests/crd-invalid rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-invalid diff --git a/testdata/manifests/crd-invalid-upgrade.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-invalid-upgrade.json similarity index 92% rename from testdata/manifests/crd-invalid-upgrade.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-invalid-upgrade.json index 4131a68fb..3c95ccb25 100644 --- a/testdata/manifests/crd-invalid-upgrade.json +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-invalid-upgrade.json @@ -24,11 +24,8 @@ "type":"integer" }, "enum": { - "type":"integer", - "enum":[ - 1, - 2 - ] + "type": "string", + "enum": ["a", "b"] }, "minMaxValue": { "type":"integer", diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-new.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-new.json new file mode 100644 index 000000000..6fed77fc1 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-new.json @@ -0,0 +1,40 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "widgets.example.com" + }, + "spec": { + "group": "example.com", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "field": { + "type": "string", + "format": "email" + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "widgets", + "singular": "widget", + "kind": "Widget" + } + } +} + diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-old.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-old.json new file mode 100644 index 000000000..a87fbd505 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-unhandled-old.json @@ -0,0 +1,39 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "widgets.example.com" + }, + "spec": { + "group": "example.com", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "widgets", + "singular": "widget", + "kind": "Widget" + } + } +} + diff --git a/testdata/manifests/crd-valid-upgrade.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-valid-upgrade.json similarity index 93% rename from testdata/manifests/crd-valid-upgrade.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-valid-upgrade.json index 52380dc92..cbc2e3ec1 100644 --- a/testdata/manifests/crd-valid-upgrade.json +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-valid-upgrade.json @@ -22,7 +22,8 @@ "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c", "adding-enum-is-allowed"] }, "minMaxValue": { "type":"integer" @@ -69,7 +70,8 @@ "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c", "adding-enum-is-allowed"] }, "minMaxValue": { "type":"integer" @@ -116,7 +118,8 @@ "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c", "adding-enum-is-allowed"] }, "minMaxValue": { "type":"integer" diff --git a/testdata/manifests/no-crds.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/no-crds.json similarity index 100% rename from testdata/manifests/no-crds.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/no-crds.json diff --git a/testdata/manifests/old-crd.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/old-crd.json similarity index 93% rename from testdata/manifests/old-crd.json rename to internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/old-crd.json index 08e763451..5a8c55b32 100644 --- a/testdata/manifests/old-crd.json +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/old-crd.json @@ -16,13 +16,15 @@ "type": "object", "properties": { "spec": { + "description": "description one", "type": "object", "properties": { "removedField": { "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c"] }, "minMaxValue": { "type":"integer" @@ -69,7 +71,8 @@ "type":"integer" }, "enum": { - "type":"integer" + "type": "string", + "enum": ["a", "b", "c"] }, "minMaxValue": { "type":"integer" diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/unhandled_message_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/unhandled_message_test.go new file mode 100644 index 000000000..59078655a --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/unhandled_message_test.go @@ -0,0 +1,28 @@ +package crdupgradesafety + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConciseUnhandledMessage_NoPrefix(t *testing.T) { + raw := "some other error" + require.Equal(t, raw, conciseUnhandledMessage(raw)) +} + +func TestConciseUnhandledMessage_SingleChange(t *testing.T) { + raw := "unhandled changes found :\n- Format: \"\"\n+ Format: \"email\"\n" + require.Equal(t, "unhandled changes found (Format \"\" -> \"email\")", conciseUnhandledMessage(raw)) +} + +func TestConciseUnhandledMessage_MultipleChanges(t *testing.T) { + raw := "unhandled changes found :\n- Format: \"\"\n+ Format: \"email\"\n- Default: nil\n+ Default: \"value\"\n- Title: \"\"\n+ Title: \"Widget\"\n- Description: \"old\"\n+ Description: \"new\"\n" + got := conciseUnhandledMessage(raw) + require.Equal(t, "unhandled changes found (Format \"\" -> \"email\"; Default nil -> \"value\"; Title \"\" -> \"Widget\"; Description \"old\" -> \"new\")", got) +} + +func TestConciseUnhandledMessage_SkipComplexValues(t *testing.T) { + raw := "unhandled changes found :\n- Default: &v1.JSONSchemaProps{}\n+ Default: &v1.JSONSchemaProps{Type: \"string\"}\n" + require.Equal(t, "unhandled changes found (Default -> (changed))", conciseUnhandledMessage(raw)) +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator.go deleted file mode 100644 index 6fec6cbe5..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator.go +++ /dev/null @@ -1,123 +0,0 @@ -// Originally copied from https://github.com/carvel-dev/kapp/tree/d7fc2e15439331aa3a379485bb124e91a0829d2e -// Attribution: -// Copyright 2024 The Carvel Authors. -// SPDX-License-Identifier: Apache-2.0 - -package crdupgradesafety - -import ( - "errors" - "fmt" - "strings" - - "github.com/openshift/crd-schema-checker/pkg/manifestcomparators" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" -) - -// Validation is a representation of a validation to run -// against a CRD being upgraded -type Validation interface { - // Validate contains the actual validation logic. An error being - // returned means validation has failed - Validate(old, new apiextensionsv1.CustomResourceDefinition) error - // Name returns a human-readable name for the validation - Name() string -} - -// ValidateFunc is a function to validate a CustomResourceDefinition -// for safe upgrades. It accepts the old and new CRDs and returns an -// error if performing an upgrade from old -> new is unsafe. -type ValidateFunc func(old, new apiextensionsv1.CustomResourceDefinition) error - -// ValidationFunc is a helper to wrap a ValidateFunc -// as an implementation of the Validation interface -type ValidationFunc struct { - name string - validateFunc ValidateFunc -} - -func NewValidationFunc(name string, vfunc ValidateFunc) Validation { - return &ValidationFunc{ - name: name, - validateFunc: vfunc, - } -} - -func (vf *ValidationFunc) Name() string { - return vf.name -} - -func (vf *ValidationFunc) Validate(old, new apiextensionsv1.CustomResourceDefinition) error { - return vf.validateFunc(old, new) -} - -type Validator struct { - Validations []Validation -} - -func (v *Validator) Validate(old, new apiextensionsv1.CustomResourceDefinition) error { - validateErrs := []error{} - for _, validation := range v.Validations { - if err := validation.Validate(old, new); err != nil { - formattedErr := fmt.Errorf("CustomResourceDefinition %s failed upgrade safety validation. %q validation failed: %w", - new.Name, validation.Name(), err) - - validateErrs = append(validateErrs, formattedErr) - } - } - if len(validateErrs) > 0 { - return errors.Join(validateErrs...) - } - return nil -} - -func NoScopeChange(old, new apiextensionsv1.CustomResourceDefinition) error { - if old.Spec.Scope != new.Spec.Scope { - return fmt.Errorf("scope changed from %q to %q", old.Spec.Scope, new.Spec.Scope) - } - return nil -} - -func NoStoredVersionRemoved(old, new apiextensionsv1.CustomResourceDefinition) error { - newVersions := sets.New[string]() - for _, version := range new.Spec.Versions { - if !newVersions.Has(version.Name) { - newVersions.Insert(version.Name) - } - } - - for _, storedVersion := range old.Status.StoredVersions { - if !newVersions.Has(storedVersion) { - return fmt.Errorf("stored version %q removed", storedVersion) - } - } - - return nil -} - -func NoExistingFieldRemoved(old, new apiextensionsv1.CustomResourceDefinition) error { - reg := manifestcomparators.NewRegistry() - err := reg.AddComparator(manifestcomparators.NoFieldRemoval()) - if err != nil { - return err - } - - results, errs := reg.Compare(&old, &new) - if len(errs) > 0 { - return errors.Join(errs...) - } - - errSet := []error{} - - for _, result := range results { - if len(result.Errors) > 0 { - errSet = append(errSet, errors.New(strings.Join(result.Errors, "\n"))) - } - } - if len(errSet) > 0 { - return errors.Join(errSet...) - } - - return nil -} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator_test.go deleted file mode 100644 index e13ac9487..000000000 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/validator_test.go +++ /dev/null @@ -1,340 +0,0 @@ -// Originally copied from https://github.com/carvel-dev/kapp/tree/d7fc2e15439331aa3a379485bb124e91a0829d2e -// Attribution: -// Copyright 2024 The Carvel Authors. -// SPDX-License-Identifier: Apache-2.0 - -package crdupgradesafety - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func TestValidator(t *testing.T) { - for _, tc := range []struct { - name string - validations []Validation - shouldErr bool - }{ - { - name: "no validators, no error", - validations: []Validation{}, - }, - { - name: "passing validator, no error", - validations: []Validation{ - NewValidationFunc("pass", func(_, _ apiextensionsv1.CustomResourceDefinition) error { - return nil - }), - }, - }, - { - name: "failing validator, error", - validations: []Validation{ - NewValidationFunc("fail", func(_, _ apiextensionsv1.CustomResourceDefinition) error { - return errors.New("boom") - }), - }, - shouldErr: true, - }, - { - name: "passing+failing validator, error", - validations: []Validation{ - NewValidationFunc("pass", func(_, _ apiextensionsv1.CustomResourceDefinition) error { - return nil - }), - NewValidationFunc("fail", func(_, _ apiextensionsv1.CustomResourceDefinition) error { - return errors.New("boom") - }), - }, - shouldErr: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - v := Validator{ - Validations: tc.validations, - } - var o, n apiextensionsv1.CustomResourceDefinition - - err := v.Validate(o, n) - require.Equal(t, tc.shouldErr, err != nil) - }) - } -} - -func TestNoScopeChange(t *testing.T) { - for _, tc := range []struct { - name string - old apiextensionsv1.CustomResourceDefinition - new apiextensionsv1.CustomResourceDefinition - shouldError bool - }{ - { - name: "no scope change, no error", - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Scope: apiextensionsv1.ClusterScoped, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Scope: apiextensionsv1.ClusterScoped, - }, - }, - }, - { - name: "scope change, error", - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Scope: apiextensionsv1.ClusterScoped, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Scope: apiextensionsv1.NamespaceScoped, - }, - }, - shouldError: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := NoScopeChange(tc.old, tc.new) - require.Equal(t, tc.shouldError, err != nil) - }) - } -} - -func TestNoStoredVersionRemoved(t *testing.T) { - for _, tc := range []struct { - name string - old apiextensionsv1.CustomResourceDefinition - new apiextensionsv1.CustomResourceDefinition - shouldError bool - }{ - { - name: "no stored versions, no error", - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - }, - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{}, - }, - { - name: "stored versions, no stored version removed, no error", - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - }, - { - Name: "v1alpha2", - }, - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Status: apiextensionsv1.CustomResourceDefinitionStatus{ - StoredVersions: []string{ - "v1alpha1", - }, - }, - }, - }, - { - name: "stored versions, stored version removed, error", - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha2", - }, - }, - }, - }, - old: apiextensionsv1.CustomResourceDefinition{ - Status: apiextensionsv1.CustomResourceDefinitionStatus{ - StoredVersions: []string{ - "v1alpha1", - }, - }, - }, - shouldError: true, - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := NoStoredVersionRemoved(tc.old, tc.new) - require.Equal(t, tc.shouldError, err != nil) - }) - } -} - -func TestNoExistingFieldRemoved(t *testing.T) { - for _, tc := range []struct { - name string - new apiextensionsv1.CustomResourceDefinition - old apiextensionsv1.CustomResourceDefinition - shouldError bool - }{ - { - name: "no existing field removed, no error", - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - name: "existing field removed, error", - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - "fieldTwo": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - shouldError: true, - }, - { - name: "new version is added with the field removed, no error", - old: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - "fieldTwo": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - new: apiextensionsv1.CustomResourceDefinition{ - Spec: apiextensionsv1.CustomResourceDefinitionSpec{ - Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ - { - Name: "v1alpha1", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - "fieldTwo": { - Type: "string", - }, - }, - }, - }, - }, - { - Name: "v1alpha2", - Schema: &apiextensionsv1.CustomResourceValidation{ - OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ - Type: "object", - Properties: map[string]apiextensionsv1.JSONSchemaProps{ - "fieldOne": { - Type: "string", - }, - }, - }, - }, - }, - }, - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - err := NoExistingFieldRemoved(tc.old, tc.new) - assert.Equal(t, tc.shouldError, err != nil) - }) - } -} diff --git a/vendor/github.com/openshift/crd-schema-checker/LICENSE b/internal/thirdparty/crdify/LICENSE similarity index 99% rename from vendor/github.com/openshift/crd-schema-checker/LICENSE rename to internal/thirdparty/crdify/LICENSE index 261eeb9e9..d64569567 100644 --- a/vendor/github.com/openshift/crd-schema-checker/LICENSE +++ b/internal/thirdparty/crdify/LICENSE @@ -1,3 +1,4 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ diff --git a/internal/thirdparty/crdify/README.md b/internal/thirdparty/crdify/README.md new file mode 100644 index 000000000..8dd19a47a --- /dev/null +++ b/internal/thirdparty/crdify/README.md @@ -0,0 +1,57 @@ +# Embedded Third-Party Code: crdify + +This directory contains an embedded copy of the `sigs.k8s.io/crdify` package, which is used for performing CRD upgrade safety checks in the operator-controller. + +## Why This Code Is Embedded + +We've embedded this third-party code directly into our repository for the following reasons: + +1. **Ease of Maintenance**: By embedding the code, we maintain full control over the version and can ensure compatibility with our current codebase without being affected by upstream changes that might introduce breaking changes or require dependency updates. + +2. **Cherry-Pick Bug Fixes**: Having the code in our repository allows us to easily cherry-pick specific bug fixes from upstream without needing to upgrade to a newer version that might have other incompatibilities or require Go version upgrades. + +3. **Go Version Compatibility**: The newer versions of `crdify` (v0.5.0+) require Go 1.24+, which is not supported in our current environment. By embedding a compatible version, we can continue using the functionality without needing to upgrade our Go toolchain. + +## Version Information + +- **Embedded Version**: `v0.4.1-0.20250613143457-398e4483fb58` +- **Source Repository**: `sigs.k8s.io/crdify` +- **License**: Apache License 2.0 (see [LICENSE](./LICENSE)) + +This version was ported from the `release-4.20` branch and is compatible with our current Go version requirements. + +## Usage + +This embedded code is used by the CRD upgrade safety preflight checks located in: +- `internal/operator-controller/rukpak/preflights/crdupgradesafety/` + +The code has been adapted to use local imports instead of the upstream package: +- `sigs.k8s.io/crdify/pkg/config` → `github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config` +- `sigs.k8s.io/crdify/pkg/runner` → `github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/runner` +- `sigs.k8s.io/crdify/pkg/validations` → `github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations` +- `sigs.k8s.io/crdify/pkg/validations/property` → `github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/property` + +## Updating This Code + +If you need to update this embedded code: + +1. Identify the specific version or commit from upstream that you want to use +2. Ensure it's compatible with our Go version requirements +3. Copy the updated code into this directory +4. Update the import paths in the consuming code +5. Update this README with the new version information +6. Test thoroughly to ensure compatibility + +## Verification + +To verify that the port from `release-4.20` is complete and only imports have changed: + +```bash +git diff upstream/release-4.20 -- internal/operator-controller/rukpak/preflights/crdupgradesafety +``` + +To verify that all testdata has been ported: + +```bash +git diff upstream/release-4.20 -- internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests +``` diff --git a/internal/thirdparty/crdify/pkg/config/config.go b/internal/thirdparty/crdify/pkg/config/config.go new file mode 100644 index 000000000..823861cf8 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/config/config.go @@ -0,0 +1,264 @@ +// 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 config + +import ( + "errors" + "fmt" + "io" + "log" + "os" + + "sigs.k8s.io/yaml" +) + +// EnforcementPolicy is a representation of how validations +// should be enforced. +type EnforcementPolicy string + +const ( + // EnforcementPolicyError is used to represent that a validation + // should report an error when identifying incompatible changes. + EnforcementPolicyError EnforcementPolicy = "Error" + + // EnforcementPolicyWarn is used to represent that a validation + // should report a warning when identifying incompatible changes. + EnforcementPolicyWarn EnforcementPolicy = "Warn" + + // EnforcementPolicyNone is used to represent that a validation + // should not report anything when identifying incompatible changes. + EnforcementPolicyNone EnforcementPolicy = "None" +) + +// ConversionPolicy is a representation of how the served version +// validator should react when a CRD specifies a conversion +// strategy. +type ConversionPolicy string + +const ( + // ConversionPolicyNone is used to represent that the served + // version validator should not treat CRDs with a conversion strategy + // differently and should run all the validations on detected changes + // across served versions of the CRD. + ConversionPolicyNone ConversionPolicy = "None" + + // ConversionPolicyIgnore is used to represent that the served + // version validator should treat CRDs with a conversion strategy + // specified as a valid reason to skip running the validations + // on changes across served versions of the CRD. + ConversionPolicyIgnore ConversionPolicy = "Ignore" +) + +// Config is the configuration used for dictating how validations +// and validators should be configured. +type Config struct { + // validations is an optional field used to configure the set of + // validations that should be run during comparisons. + // + // Configuration of validations is strictly additive. + // Default behaviors of validations will be used in the + // event they are not included in the set of configured validations. + Validations []ValidationConfig `json:"validations"` + + // unhandledEnforcement is an optional field used to configure + // how changes that have not been handled by an existing validation + // should be treated. + // + // Allowed values are Error, Warn, and None. + // + // When set to Error, any unhandled changes will result in an error. + // + // When set to Warn, any unhandled changes will result in a warning. + // + // When set to None, unhandled changes will be ignored. + // Defaults to Error. + UnhandledEnforcement EnforcementPolicy `json:"unhandledEnforcement"` + + // conversion is an optional field used to configure how validations + // are run against served versions of the CRD. + // + // Allowed values are None and Ignore. + // + // When set to Ignore, if a conversion strategy of "Webhook" is specified served + // versions will not be validated. + // + // When set to None, even if a conversion strategy of "Webhook" is specified served + // versions will be validated. + // Defaults to None. + Conversion ConversionPolicy `json:"conversion"` +} + +// ValidationConfig is used to dictate how individual validations +// should be configured. +type ValidationConfig struct { + // name is a required field used to specify a validation. + // name must be a known validation. + Name string `json:"name"` + + // enforcement is a required field used to specify how a validation + // should be enforced. + // + // Allowed values are Error, Warn, and None. + // + // When set to Error, any incompatibilities found by the validation + // will result in an error message. + // + // When set to Warn, any incompatibilities found by the validation + // will result in a warning message. + // + // When set to None, the validation will not be run. + Enforcement EnforcementPolicy `json:"enforcement"` + + // configuration is an optional field used to specify a configuration + // for the validation. + Configuration map[string]interface{} `json:"configuration"` +} + +// Load reads a file into a Config object and validates it. +// If there are any errors encountered while loading the file contents +// into the Config object a nil value and error will be returned. +// Otherwise, a pointer to the Config object will be returned alongside a nil error. +func Load(configFile string) (*Config, error) { + cfg := &Config{} + + if configFile != "" { + //nolint:gosec + file, err := os.Open(configFile) + if err != nil { + return nil, fmt.Errorf("loading config file %q: %w", configFile, err) + } + + configBytes, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("reading config file %q: %w", configFile, err) + } + + err = file.Close() + if err != nil { + log.Print("failed to close config file after reading", configFile, err) + } + + err = yaml.Unmarshal(configBytes, cfg) + if err != nil { + return nil, fmt.Errorf("unmarshalling config file %q contents: %w", configFile, err) + } + } + + err := ValidateConfig(cfg) + if err != nil { + return nil, err + } + + return cfg, nil +} + +// ValidateConfig ensures a valid Config object. +// It will set defaults where appropriate and return an error +// if user specified values are invalid. +func ValidateConfig(cfg *Config) error { + if cfg == nil { + // nothing to validate + return nil + } + + validationErr := ValidateValidations(cfg.Validations...) + unhandledEnforcementErr := ValidateEnforcementPolicy(&cfg.UnhandledEnforcement, false) + conversionErr := ValidateConversionPolicy(&cfg.Conversion) + + return errors.Join(validationErr, unhandledEnforcementErr, conversionErr) +} + +// ValidateConversionPolicy ensures the provided ConversionPolicy +// is valid. +// It will modify the ConversionPolicy to set it to the +// default value of "None" if it is the empty string (""). +// Returns an error if an invalid ConversionPolicy is specified. +func ValidateConversionPolicy(policy *ConversionPolicy) error { + if policy == nil { + // nothing to validate + return nil + } + + var err error + + switch *policy { + case ConversionPolicyNone, ConversionPolicyIgnore: + // do nothing, valid values + case ConversionPolicy(""): + // default to None + *policy = ConversionPolicyNone + default: + err = fmt.Errorf("%w: %q", errUnknownConversionPolicy, *policy) + } + + return err +} + +var errUnknownConversionPolicy = errors.New("unknown conversion policy") + +// ValidateEnforcementPolicy ensures the provided EnforcementPolicy +// is valid. +// It will modify the EnforcementPolicy to set it to the +// default value of "Error" if it is the empty string ("") and the EnforcementPolicy +// is not required. +// Returns an error if an invalid EnforcementPolicy is specified. +func ValidateEnforcementPolicy(policy *EnforcementPolicy, required bool) error { + if policy == nil { + // nothing to validate + return nil + } + + var err error + + switch *policy { + case EnforcementPolicyError, EnforcementPolicyWarn, EnforcementPolicyNone: + // do nothing, valid values + case EnforcementPolicy(""): + if required { + err = errEnforcementRequired + break + } + // default to error + *policy = EnforcementPolicyError + default: + err = fmt.Errorf("%w: %q", errUnknownEnforcement, string(*policy)) + } + + return err +} + +var ( + errEnforcementRequired = errors.New("enforcement is required") + errUnknownEnforcement = errors.New("unknown enforcement") +) + +// ValidateValidations loops through the provided ValidationConfig +// items to ensure they are valid. +// Returns an aggregated error of the invalid ValidationConfig items. +func ValidateValidations(validations ...ValidationConfig) error { + errs := []error{} + + for i, validation := range validations { + if validation.Name == "" { + errs = append(errs, fmt.Errorf("validations[%d] is invalid: %w", i, errNameRequired)) + } + + errs = append(errs, ValidateEnforcementPolicy(&validation.Enforcement, true)) + } + + return errors.Join(errs...) +} + +var errNameRequired = errors.New("name is required") diff --git a/internal/thirdparty/crdify/pkg/runner/registry.go b/internal/thirdparty/crdify/pkg/runner/registry.go new file mode 100644 index 000000000..9e91ed6d5 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/runner/registry.go @@ -0,0 +1,50 @@ +// 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 runner + +import ( + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/crd/existingfieldremoval" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/crd/scope" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/crd/storedversionremoval" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations/property" +) + +//nolint:gochecknoglobals +var defaultRegistry = validations.NewRegistry() + +func init() { + existingfieldremoval.Register(defaultRegistry) + scope.Register(defaultRegistry) + storedversionremoval.Register(defaultRegistry) + property.RegisterDefault(defaultRegistry) + property.RegisterEnum(defaultRegistry) + property.RegisterMaximum(defaultRegistry) + property.RegisterMaxItems(defaultRegistry) + property.RegisterMaxLength(defaultRegistry) + property.RegisterMaxProperties(defaultRegistry) + property.RegisterMinimum(defaultRegistry) + property.RegisterMinItems(defaultRegistry) + property.RegisterMinLength(defaultRegistry) + property.RegisterMinProperties(defaultRegistry) + property.RegisterRequired(defaultRegistry) + property.RegisterType(defaultRegistry) + property.RegisterDescription(defaultRegistry) +} + +// DefaultRegistry returns a pre-configured validations.Registry. +func DefaultRegistry() validations.Registry { + return defaultRegistry +} diff --git a/internal/thirdparty/crdify/pkg/runner/results.go b/internal/thirdparty/crdify/pkg/runner/results.go new file mode 100644 index 000000000..93c366d87 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/runner/results.go @@ -0,0 +1,299 @@ +// 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 runner + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + "gopkg.in/yaml.v2" +) + +// Results is a utility type to hold the validation results of +// running different validators. +type Results struct { + // CRDValidation is the set of validation comparison results + // at the whole CustomResourceDefinition scope + CRDValidation []validations.ComparisonResult `json:"crdValidation,omitempty"` + + // SameVersionValidation is the set of validation comparison + // results at the CustomResourceDefinition version level. Specifically + // for same version comparisons across an old and new CustomResourceDefinition + // instance (i.e comparing v1alpha1 with v1alpha1) + SameVersionValidation map[string]map[string][]validations.ComparisonResult `json:"sameVersionValidation,omitempty"` + + // ServedVersionValidation is the set of validation comparison + // results at the CustomResourceDefinition version level. Specifically + // for served version comparisons across an old and new CustomResourceDefinition + // instance (i.e comparing v1alpha1 with v1 if both are served) + ServedVersionValidation map[string]map[string][]validations.ComparisonResult `json:"servedVersionValidation,omitempty"` +} + +// Format is a representation of an output format. +type Format string + +const ( + // FormatJSON represents a JSON output format. + FormatJSON Format = "json" + + // FormatYAML represents a YAML output format. + FormatYAML Format = "yaml" + + // FormatPlainText represents a PlainText output format. + FormatPlainText Format = "plaintext" + + // FormatMarkdown represents a Markdown output format. + FormatMarkdown Format = "markdown" +) + +// Render returns the string representation of the provided +// format or an error if one is encountered. +// Currently supported render formats are json, yaml, plaintext, and markdown. +// Unknown formats will result in an error. +func (rr *Results) Render(format Format) (string, error) { + switch format { + case FormatJSON: + return rr.RenderJSON() + case FormatYAML: + return rr.RenderYAML() + case FormatMarkdown: + return rr.RenderMarkdown(), nil + case FormatPlainText: + return rr.RenderPlainText(), nil + default: + return "", fmt.Errorf("%w : %q", errUnknownRenderFormat, format) + } +} + +var errUnknownRenderFormat = errors.New("unknown render format") + +// RenderJSON returns a string of the results rendered in JSON or an error. +func (rr *Results) RenderJSON() (string, error) { + outBytes, err := json.MarshalIndent(rr, "", " ") + return string(outBytes), err +} + +// RenderYAML returns a string of the results rendered in YAML or an error. +func (rr *Results) RenderYAML() (string, error) { + outBytes, err := yaml.Marshal(rr) + return string(outBytes), err +} + +// RenderMarkdown returns a string of the results rendered as Markdown +// +//nolint:dupl +func (rr *Results) RenderMarkdown() string { //nolint:gocognit,cyclop + var out strings.Builder + + out.WriteString("# CRD Validations\n") + + for _, result := range rr.CRDValidation { + if len(result.Errors) > 0 { + for _, err := range result.Errors { + out.WriteString(fmt.Sprintf("- **%s** - `ERROR` - %s\n", result.Name, err)) + } + } + + if len(result.Warnings) > 0 { + for _, err := range result.Warnings { + out.WriteString(fmt.Sprintf("- **%s** - `WARNING` - %s\n", result.Name, err)) + } + } + + if len(result.Errors) == 0 && len(result.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- **%s** - ✓\n", result.Name)) + } + } + + out.WriteString("\n\n") + out.WriteString("# Same Version Validations\n") + + for version, result := range rr.SameVersionValidation { + for property, results := range result { + for _, propertyResult := range results { + if len(propertyResult.Errors) > 0 { + for _, err := range propertyResult.Errors { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `ERROR` - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Warnings) > 0 { + for _, err := range propertyResult.Warnings { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `WARNING` - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - ✓\n", version, property, propertyResult.Name)) + } + } + } + } + + out.WriteString("\n\n") + out.WriteString("# Served Version Validations\n") + + for version, result := range rr.ServedVersionValidation { + for property, results := range result { + for _, propertyResult := range results { + if len(propertyResult.Errors) > 0 { + for _, err := range propertyResult.Errors { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `ERROR` - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Warnings) > 0 { + for _, err := range propertyResult.Warnings { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `WARNING` - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - ✓\n", version, property, propertyResult.Name)) + } + } + } + } + + return out.String() +} + +// RenderPlainText returns a string of the results rendered as PlainText +// +//nolint:dupl +func (rr *Results) RenderPlainText() string { //nolint:gocognit,cyclop + var out strings.Builder + + out.WriteString("CRD Validations\n") + + for _, result := range rr.CRDValidation { + if len(result.Errors) > 0 { + for _, err := range result.Errors { + out.WriteString(fmt.Sprintf("- %s - ERROR - %s\n", result.Name, err)) + } + } + + if len(result.Warnings) > 0 { + for _, err := range result.Warnings { + out.WriteString(fmt.Sprintf("- %s - WARNING - %s\n", result.Name, err)) + } + } + + if len(result.Errors) == 0 && len(result.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- %s - ✓\n", result.Name)) + } + } + + out.WriteString("\n\n") + out.WriteString("Same Version Validations\n") + + for version, result := range rr.SameVersionValidation { + for property, results := range result { + for _, propertyResult := range results { + if len(propertyResult.Errors) > 0 { + for _, err := range propertyResult.Errors { + out.WriteString(fmt.Sprintf("- %s - %s - %s - ERROR - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Warnings) > 0 { + for _, err := range propertyResult.Warnings { + out.WriteString(fmt.Sprintf("- %s - %s - %s - WARNING - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- %s - %s - %s - ✓\n", version, property, propertyResult.Name)) + } + } + } + } + + out.WriteString("\n\n") + out.WriteString("Served Version Validations\n") + + for version, result := range rr.ServedVersionValidation { + for property, results := range result { + for _, propertyResult := range results { + if len(propertyResult.Errors) > 0 { + for _, err := range propertyResult.Errors { + out.WriteString(fmt.Sprintf("- %s - %s - %s - ERROR - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Warnings) > 0 { + for _, err := range propertyResult.Warnings { + out.WriteString(fmt.Sprintf("- %s - %s - %s - WARNING - %s\n", version, property, propertyResult.Name, err)) + } + } + + if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { + out.WriteString(fmt.Sprintf("- %s - %s - %s - ✓\n", version, property, propertyResult.Name)) + } + } + } + } + + return out.String() +} + +// HasFailures returns a boolean signaling if any of the validation results contain any errors. +func (rr *Results) HasFailures() bool { + return rr.HasCRDValidationFailures() || rr.HasSameVersionValidationFailures() || rr.HasServedVersionValidationFailures() +} + +// HasCRDValidationFailures returns a boolean signaling if the CRD scoped validations contain any errors. +func (rr *Results) HasCRDValidationFailures() bool { + for _, result := range rr.CRDValidation { + if len(result.Errors) > 0 { + return true + } + } + + return false +} + +// HasSameVersionValidationFailures returns a boolean signaling if the same version validations contain any errors. +func (rr *Results) HasSameVersionValidationFailures() bool { + for _, versionResults := range rr.SameVersionValidation { + for _, propertyResults := range versionResults { + for _, result := range propertyResults { + if len(result.Errors) > 0 { + return true + } + } + } + } + + return false +} + +// HasServedVersionValidationFailures returns a boolean signaling if the served version validations contain any errors. +func (rr *Results) HasServedVersionValidationFailures() bool { + for _, versionResults := range rr.ServedVersionValidation { + for _, propertyResults := range versionResults { + for _, result := range propertyResults { + if len(result.Errors) > 0 { + return true + } + } + } + } + + return false +} diff --git a/internal/thirdparty/crdify/pkg/runner/runner.go b/internal/thirdparty/crdify/pkg/runner/runner.go new file mode 100644 index 000000000..9f735d436 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/runner/runner.go @@ -0,0 +1,74 @@ +// 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 runner + +import ( + "fmt" + "maps" + "slices" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validators/crd" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validators/version/same" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validators/version/served" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Runner is a utility struct for running +// - Whole CRD validations +// - Same version validations +// - Served version validations. +type Runner struct { + crdValidator *crd.Validator + sameVersionValidator *same.Validator + servedVersionValidator *served.Validator +} + +// New returns a new instance of a Runner using the provided Config and validations.Registry +// to build the CRD, same version, and served version validators. +// It returns an error if any errors are encountered. +func New(cfg *config.Config, registry validations.Registry) (*Runner, error) { + initialValidations, err := validations.LoadValidationsFromRegistry(registry) + if err != nil { + return nil, fmt.Errorf("loading validations from registry: %w", err) + } + + configuredValidations, err := validations.ConfigureValidations(initialValidations, registry, *cfg) + if err != nil { + return nil, fmt.Errorf("configuring validations: %w", err) + } + + vals := slices.Collect(maps.Values(configuredValidations)) + + crdComparators := validations.ComparatorsForValidations[apiextensionsv1.CustomResourceDefinition](vals...) + propertyComparators := validations.ComparatorsForValidations[apiextensionsv1.JSONSchemaProps](vals...) + + return &Runner{ + crdValidator: crd.New(crd.WithComparators(crdComparators...)), + sameVersionValidator: same.New(same.WithComparators(propertyComparators...), same.WithUnhandledEnforcementPolicy(cfg.UnhandledEnforcement)), + servedVersionValidator: served.New(served.WithComparators(propertyComparators...), served.WithUnhandledEnforcementPolicy(cfg.UnhandledEnforcement), served.WithConversionPolicy(cfg.Conversion)), + }, nil +} + +// Run executes all the validators and collects the results into a utility struct for +// reporting and evaluating the results. +func (i *Runner) Run(oldCrd, newCrd *apiextensionsv1.CustomResourceDefinition) *Results { + return &Results{ + CRDValidation: i.crdValidator.Validate(oldCrd, newCrd), + SameVersionValidation: i.sameVersionValidator.Validate(oldCrd, newCrd), + ServedVersionValidation: i.servedVersionValidator.Validate(oldCrd, newCrd), + } +} diff --git a/internal/thirdparty/crdify/pkg/slices/translate.go b/internal/thirdparty/crdify/pkg/slices/translate.go new file mode 100644 index 000000000..c8a066803 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/slices/translate.go @@ -0,0 +1,27 @@ +// 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 slices + +// Translate is a generic function for translating an input slice +// of type S into a new slice of type E. +func Translate[S any, E any](translation func(S) E, in ...S) []E { + e := []E{} + + for _, s := range in { + e = append(e, translation(s)) + } + + return e +} diff --git a/internal/thirdparty/crdify/pkg/validations/compare.go b/internal/thirdparty/crdify/pkg/validations/compare.go new file mode 100644 index 000000000..62316a87e --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/compare.go @@ -0,0 +1,82 @@ +// 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 validations + +import ( + "errors" + "fmt" + + "github.com/google/go-cmp/cmp" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/equality" +) + +// CompareVersions calculates the diff in the provided old and new CustomResourceDefinitionVersions and +// compares the differing properties using the provided comparators. +// An 'unhandled' comparator will be injected to evaluate any unhandled changes by the provided comparators +// that will be enforced based on the provided unhandled enforcement policy. +// Returns a map[string][]ComparisonResult, where the map key is the flattened property path (i.e ^.spec.foo.bar). +func CompareVersions(a, b apiextensionsv1.CustomResourceDefinitionVersion, unhandledEnforcement config.EnforcementPolicy, comparators ...Comparator[apiextensionsv1.JSONSchemaProps]) map[string][]ComparisonResult { + oldFlattened := FlattenCRDVersion(a) + newFlattened := FlattenCRDVersion(b) + + diffs := FlattenedCRDVersionDiff(oldFlattened, newFlattened) + + result := map[string][]ComparisonResult{} + + for property, diff := range diffs { + result[property] = CompareProperties(diff.Old, diff.New, unhandledEnforcement, comparators...) + } + + return result +} + +// CompareProperties compares the provided JSONSchemaProps using the provided comparators. +// An 'unhandled' comparator will be injected to evaluate any unhandled changes by the provided +// comparators that will be enforced based on the provided unhandled enforcement policy. +// Returns a slice containing all the comparison results. +func CompareProperties(a, b *apiextensionsv1.JSONSchemaProps, unhandledEnforcement config.EnforcementPolicy, comparators ...Comparator[apiextensionsv1.JSONSchemaProps]) []ComparisonResult { + result := []ComparisonResult{} + aCopy, bCopy := a.DeepCopy(), b.DeepCopy() + + for _, comparator := range comparators { + comparisonResult := comparator.Compare(aCopy, bCopy) + result = append(result, comparisonResult) + } + + // checking for unhandled changes is _always_ performed last. + result = append(result, checkUnhandled(aCopy, bCopy, unhandledEnforcement)) + + return result +} + +// checkUnhandled is a utility function for checking if a provided set of comparators +// handled validating all differences between the JSONSchemaProps. +// It returns a ComparisonResult so that the results are treated generically just like a standard Comparator. +func checkUnhandled(a, b *apiextensionsv1.JSONSchemaProps, enforcement config.EnforcementPolicy) ComparisonResult { + var err error + + if !equality.Semantic.DeepEqual(a, b) { + diff := cmp.Diff(a, b) + err = fmt.Errorf("%w :\n%s", ErrUnhandledChangesFound, diff) + } + + return HandleErrors("unhandled", enforcement, err) +} + +// ErrUnhandledChangesFound represents an error state where changes have been found that are not +// handled by an existing validation check. +var ErrUnhandledChangesFound = errors.New("unhandled changes found") diff --git a/internal/thirdparty/crdify/pkg/validations/crd/existingfieldremoval/existingfieldremoval.go b/internal/thirdparty/crdify/pkg/validations/crd/existingfieldremoval/existingfieldremoval.go new file mode 100644 index 000000000..2926f8c22 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/crd/existingfieldremoval/existingfieldremoval.go @@ -0,0 +1,105 @@ +// 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 existingfieldremoval + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +var ( + _ validations.Validation = (*ExistingFieldRemoval)(nil) + _ validations.Comparator[apiextensionsv1.CustomResourceDefinition] = (*ExistingFieldRemoval)(nil) +) + +const name = "existingFieldRemoval" + +// Register registers the ExistingFieldRemoval validation +// with the provided validation registry. +func Register(registry validations.Registry) { + registry.Register(name, factory) +} + +// factory is a function used to initialize an ExistingFieldRemoval validation +// implementation based on the provided configuration. +func factory(_ map[string]interface{}) (validations.Validation, error) { + return &ExistingFieldRemoval{}, nil +} + +// ExistingFieldRemoval is a validations.Validation implementation +// used to check if any existing fields have been removed from one +// CRD instance to another. +type ExistingFieldRemoval struct { + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the ExistingFieldRemoval validation. +func (efr *ExistingFieldRemoval) Name() string { + return name +} + +// SetEnforcement sets the EnforcementPolicy for the ExistingFieldRemoval validation. +func (efr *ExistingFieldRemoval) SetEnforcement(policy config.EnforcementPolicy) { + efr.enforcement = policy +} + +// Compare compares an old and a new CustomResourceDefintion, checking for any fields that were removed +// from the old CustomResourceDefinition in the new CustomResourceDefinition. +func (efr *ExistingFieldRemoval) Compare(a, b *apiextensionsv1.CustomResourceDefinition) validations.ComparisonResult { + errs := []error{} + + for _, newVersion := range b.Spec.Versions { + existingVersion := validations.GetCRDVersionByName(a, newVersion.Name) + if existingVersion == nil { + continue + } + + existingFields := getFields(existingVersion) + newFields := getFields(&newVersion) + + removedFields := existingFields.Difference(newFields) + for _, removedField := range removedFields.UnsortedList() { + errs = append(errs, fmt.Errorf("%w : %v.%v", ErrRemovedExistingField, newVersion.Name, removedField)) + } + } + + return validations.HandleErrors(efr.Name(), efr.enforcement, errs...) +} + +// ErrRemovedExistingField represents an error state where existing fields have been removed +// from the CustomResourceDefinition. +var ErrRemovedExistingField = errors.New("removed field") + +// getFields returns a set of all the fields for the provided CustomResourceDefinitionVersion. +func getFields(v *apiextensionsv1.CustomResourceDefinitionVersion) sets.Set[string] { + fields := sets.New[string]() + + validations.SchemaHas(v.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, + func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { + fields.Insert(simpleLocation.String()) + return false + }, + ) + + return fields +} diff --git a/internal/thirdparty/crdify/pkg/validations/crd/scope/scope.go b/internal/thirdparty/crdify/pkg/validations/crd/scope/scope.go new file mode 100644 index 000000000..e614505e8 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/crd/scope/scope.go @@ -0,0 +1,76 @@ +// 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 scope + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +var ( + _ validations.Validation = (*Scope)(nil) + _ validations.Comparator[apiextensionsv1.CustomResourceDefinition] = (*Scope)(nil) +) + +const name = "scope" + +// Register registers the Scope validation +// with the provided validation registry. +func Register(registry validations.Registry) { + registry.Register(name, factory) +} + +// factory is a function used to initialize a Scope validation +// implementation based on the provided configuration. +func factory(_ map[string]interface{}) (validations.Validation, error) { + return &Scope{}, nil +} + +// Scope is a validations.Validation implementation +// used to check if the scope has changed from one +// CRD instance to another. +type Scope struct { + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Scope validation. +func (s *Scope) Name() string { + return name +} + +// SetEnforcement sets the EnforcementPolicy for the Scope validation. +func (s *Scope) SetEnforcement(enforcement config.EnforcementPolicy) { + s.enforcement = enforcement +} + +// Compare compares an old and a new CustomResourceDefintion, checking for any change to the scope from the +// old CustomResourceDefinition to the new CustomResourceDefinition. +func (s *Scope) Compare(a, b *apiextensionsv1.CustomResourceDefinition) validations.ComparisonResult { + var err error + if a.Spec.Scope != b.Spec.Scope { + err = fmt.Errorf("%w : %q -> %q", ErrChangedScope, a.Spec.Scope, b.Spec.Scope) + } + + return validations.HandleErrors(s.Name(), s.enforcement, err) +} + +// ErrChangedScope represents an error state where the scope of the CustomResourceDefinition has changed. +var ErrChangedScope = errors.New("scope changed") diff --git a/internal/thirdparty/crdify/pkg/validations/crd/storedversionremoval/storedversionremoval.go b/internal/thirdparty/crdify/pkg/validations/crd/storedversionremoval/storedversionremoval.go new file mode 100644 index 000000000..7f31ff0c6 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/crd/storedversionremoval/storedversionremoval.go @@ -0,0 +1,94 @@ +// 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 storedversionremoval + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var ( + _ validations.Validation = (*StoredVersionRemoval)(nil) + _ validations.Comparator[apiextensionsv1.CustomResourceDefinition] = (*StoredVersionRemoval)(nil) +) + +const name = "storedVersionRemoval" + +// Register registers the StoredVersionRemoval validation +// with the provided validation registry. +func Register(registry validations.Registry) { + registry.Register(name, factory) +} + +// factory is a function used to initialize a StoredVersionRemoval validation +// implementation based on the provided configuration. +func factory(_ map[string]interface{}) (validations.Validation, error) { + return &StoredVersionRemoval{}, nil +} + +// StoredVersionRemoval is a validations.Validation implementation +// used to check if any versions existing in the set of stored versions +// has been removed in the new instance of the CustomResourceDefinition. +type StoredVersionRemoval struct { + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the StoredVersionRemoval validation. +func (svr *StoredVersionRemoval) Name() string { + return name +} + +// SetEnforcement sets the EnforcementPolicy for the StoredVersionRemoval validation. +func (svr *StoredVersionRemoval) SetEnforcement(enforcement config.EnforcementPolicy) { + svr.enforcement = enforcement +} + +// Compare compares an old and a new CustomResourceDefintion, checking for removal of +// any stored versions present in the old CustomResourceDefinition in the new instance +// of the CustomResourceDefinition. +func (svr *StoredVersionRemoval) Compare(a, b *apiextensionsv1.CustomResourceDefinition) validations.ComparisonResult { + newVersions := sets.New[string]() + for _, version := range b.Spec.Versions { + if !newVersions.Has(version.Name) { + newVersions.Insert(version.Name) + } + } + + removedVersions := []string{} + + for _, storedVersion := range a.Status.StoredVersions { + if !newVersions.Has(storedVersion) { + removedVersions = append(removedVersions, storedVersion) + } + } + + var err error + if len(removedVersions) > 0 { + err = fmt.Errorf("%w : %v", ErrRemovedStoredVersions, removedVersions) + } + + return validations.HandleErrors(svr.Name(), svr.enforcement, err) +} + +// ErrRemovedStoredVersions represents an error state where stored versions have been removed +// from the CustomResourceDefinition. +var ErrRemovedStoredVersions = errors.New("stored versions removed") diff --git a/internal/thirdparty/crdify/pkg/validations/property/default.go b/internal/thirdparty/crdify/pkg/validations/property/default.go new file mode 100644 index 000000000..bbac982e3 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/default.go @@ -0,0 +1,95 @@ +// 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 property + +import ( + "bytes" + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +const defaultValidationName = "default" + +var ( + _ validations.Validation = (*Default)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Default)(nil) +) + +// RegisterDefault registers the Default validation +// with the provided validation registry. +func RegisterDefault(registry validations.Registry) { + registry.Register(defaultValidationName, defaultFactory) +} + +// defaultFactory is a function used to initialize a Default validation +// implementation based on the provided configuration. +func defaultFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Default{}, nil +} + +// Default is a Validation that can be used to identify +// incompatible changes to the default value of CRD properties. +type Default struct { + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Default validation. +func (d *Default) Name() string { + return defaultValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Default validation. +func (d *Default) SetEnforcement(policy config.EnforcementPolicy) { + d.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for changes to the default value of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Default field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (d *Default) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + var err error + + switch { + case a.Default == nil && b.Default != nil: + err = fmt.Errorf("%w : %q", ErrNetNewDefaultConstraint, string(b.Default.Raw)) + case a.Default != nil && b.Default == nil: + err = fmt.Errorf("%w : %q", ErrRemovedDefault, string(a.Default.Raw)) + case a.Default != nil && b.Default != nil && !bytes.Equal(a.Default.Raw, b.Default.Raw): + err = fmt.Errorf("%w : %q -> %q", ErrChangedDefault, string(a.Default.Raw), string(b.Default.Raw)) + } + + // reset values + a.Default = nil + b.Default = nil + + return validations.HandleErrors(d.Name(), d.enforcement, err) +} + +var ( + // ErrNetNewDefaultConstraint represents an error state where a net new default was added to a property. + ErrNetNewDefaultConstraint = errors.New("default added when there was none previously") + // ErrRemovedDefault represents an error state where the default value was removed for a property. + ErrRemovedDefault = errors.New("default value removed") + // ErrChangedDefault represents an error state where the default value was changed for a property. + ErrChangedDefault = errors.New("default value changed") +) diff --git a/internal/thirdparty/crdify/pkg/validations/property/description.go b/internal/thirdparty/crdify/pkg/validations/property/description.go new file mode 100644 index 000000000..a27115bf4 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/description.go @@ -0,0 +1,83 @@ +// 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 property + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +const descriptionValidationName = "description" + +var ( + _ validations.Validation = (*Description)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Description)(nil) +) + +// RegisterDescription registers the Description validation +// with the provided validation registry. +func RegisterDescription(registry validations.Registry) { + registry.Register(descriptionValidationName, descriptionFactory) +} + +// descriptionFactory is a function used to initialize a Description validation +// implementation based on the provided configuration. +func descriptionFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Description{}, nil +} + +// Description is a Validation that can be used to identify +// incompatible changes to the description of CRD properties. +type Description struct { + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Description validation. +func (d *Description) Name() string { + return descriptionValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Description validation. +func (d *Description) SetEnforcement(policy config.EnforcementPolicy) { + d.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for changes to the description of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Description field will be reset to '""' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (d *Description) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + var err error + + if a.Description != b.Description { + err = fmt.Errorf("%w : %q -> %q", ErrChangedDescription, a.Description, b.Description) + } + + // reset values + a.Description = "" + b.Description = "" + + return validations.HandleErrors(d.Name(), d.enforcement, err) +} + +// ErrChangedDescription represents an error state where the description was changed for a property. +var ErrChangedDescription = errors.New("description changed") diff --git a/internal/thirdparty/crdify/pkg/validations/property/enum.go b/internal/thirdparty/crdify/pkg/validations/property/enum.go new file mode 100644 index 000000000..071f90a43 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/enum.go @@ -0,0 +1,185 @@ +// 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 property + +import ( + "errors" + "fmt" + "slices" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +const enumValidationName = "enum" + +var ( + _ validations.Validation = (*Enum)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Enum)(nil) +) + +// RegisterEnum registers the Enum validation +// with the provided validation registry. +func RegisterEnum(registry validations.Registry) { + registry.Register(enumValidationName, enumFactory) +} + +// enumFactory is a function used to initialize an Enum validation +// implementation based on the provided configuration. +func enumFactory(cfg map[string]interface{}) (validations.Validation, error) { + enumCfg := &EnumConfig{} + + err := ConfigToType(cfg, enumCfg) + if err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + err = ValidateEnumConfig(enumCfg) + if err != nil { + return nil, fmt.Errorf("validating enum config: %w", err) + } + + return &Enum{EnumConfig: *enumCfg}, nil +} + +// ValidateEnumConfig validates the provided EnumConfig +// setting default values where appropriate. +// Currently the defaulting behavior defaults the +// EnumConfig.AdditionPolicy to AdditionPolicyDisallow +// if it is set to the empty string (""). +func ValidateEnumConfig(in *EnumConfig) error { + if in == nil { + // nothing to validate + return nil + } + + switch in.AdditionPolicy { + case AdditionPolicyAllow, AdditionPolicyDisallow: + // do nothing, valid case + case AdditionPolicy(""): + // default to disallow + in.AdditionPolicy = AdditionPolicyDisallow + default: + return fmt.Errorf("%w : %q", errUnknownAdditionPolicy, in.AdditionPolicy) + } + + return nil +} + +var errUnknownAdditionPolicy = errors.New("unknown addition policy") + +// AdditionPolicy is used to represent how the Enum validation +// should determine compatibility of adding new enum values to an +// existing enum constraint. +type AdditionPolicy string + +const ( + // AdditionPolicyAllow signals that adding new enum values to + // an existing enum constraint should be considered a compatible change. + AdditionPolicyAllow AdditionPolicy = "Allow" + + // AdditionPolicyDisallow signals that adding new enum values to + // an existing enum constraint should be considered an incompatible change. + AdditionPolicyDisallow AdditionPolicy = "Disallow" +) + +// EnumConfig contains additional configurations for the Enum validation. +type EnumConfig struct { + // additionPolicy is how adding enums to an existing set of + // enums should be treated. + // Allowed values are Allow and Disallow. + // When set to Allow, adding new values to an existing set + // of enums will not be flagged. + // When set to Disallow, adding new values to an existing + // set of enums will be flagged. + // Defaults to Disallow. + AdditionPolicy AdditionPolicy `json:"additionPolicy,omitempty"` +} + +// Enum is a Validation that can be used to identify +// incompatible changes to the enum values of CRD properties. +type Enum struct { + // EnumConfig is the set of additional configuration options + EnumConfig + + // enforcement is the EnforcementPolicy that this validation + // should use when performing its validation logic + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Enum validation. +func (e *Enum) Name() string { + return enumValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Enum validation. +func (e *Enum) SetEnforcement(policy config.EnforcementPolicy) { + e.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the enum constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Enum field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (e *Enum) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + oldEnums := sets.New[string]() + for _, json := range a.Enum { + oldEnums.Insert(string(json.Raw)) + } + + newEnums := sets.New[string]() + for _, json := range b.Enum { + newEnums.Insert(string(json.Raw)) + } + + removedEnums := oldEnums.Difference(newEnums) + addedEnums := newEnums.Difference(oldEnums) + + var err error + + switch { + case oldEnums.Len() == 0 && newEnums.Len() > 0: + newEnumSlice := newEnums.UnsortedList() + slices.Sort(newEnumSlice) + err = fmt.Errorf("%w : %v", ErrNetNewEnumConstraint, newEnumSlice) + case removedEnums.Len() > 0: + removedEnumSlice := removedEnums.UnsortedList() + slices.Sort(removedEnumSlice) + err = fmt.Errorf("%w : %v", ErrRemovedEnums, removedEnumSlice) + case addedEnums.Len() > 0 && e.AdditionPolicy != AdditionPolicyAllow: + addedEnumSlice := addedEnums.UnsortedList() + slices.Sort(addedEnumSlice) + err = fmt.Errorf("%w : %v", ErrAddedEnums, addedEnumSlice) + } + + a.Enum = nil + b.Enum = nil + + return validations.HandleErrors(e.Name(), e.enforcement, err) +} + +var ( + // ErrNetNewEnumConstraint represents an error state where a net new enum constraint was added to a property. + ErrNetNewEnumConstraint = errors.New("enum constraint added when there was none previously") + // ErrRemovedEnums represents an error state where at least one previously allowed enum value was removed + // from the enum constraint on a property. + ErrRemovedEnums = errors.New("allowed enum values removed") + // ErrAddedEnums represents an error state where at least one enum value, that was not previously allowed, + // was added to the enum constraint on a property. + ErrAddedEnums = errors.New("allowed enum values added") +) diff --git a/internal/thirdparty/crdify/pkg/validations/property/max.go b/internal/thirdparty/crdify/pkg/validations/property/max.go new file mode 100644 index 000000000..185bce7ed --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/max.go @@ -0,0 +1,253 @@ +// 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. + +//nolint:dupl +package property + +import ( + "cmp" + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// MaxOptions is an abstraction for the common +// options for all the "Maximum" related constraints +// on CRD properties. +type MaxOptions struct { + enforcement config.EnforcementPolicy +} + +// MaxVerification is a generic helper function for comparing +// two cmp.Ordered values. It returns an error if: +// - older value is nil and newer value is not nil +// - older and newer values are not nil, newer is less than older. +func MaxVerification[T cmp.Ordered](older, newer *T) error { + var err error + + switch { + case older == nil && newer != nil: + err = fmt.Errorf("%w : %v", ErrNetNewMaximumConstraint, *newer) + case older != nil && newer != nil && *newer < *older: + err = fmt.Errorf("%w : %v -> %v", ErrMaximumIncreased, *older, *newer) + } + + return err +} + +var ( + // ErrNetNewMaximumConstraint represents an error state where a net new maximum constraint was added to a property. + ErrNetNewMaximumConstraint = errors.New("maximum constraint added when there was none previously") + // ErrMaximumIncreased represents an error state where a maximum constaint on a property was decreased. + ErrMaximumIncreased = errors.New("maximum decreased") +) + +var ( + _ validations.Validation = (*Maximum)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Maximum)(nil) +) + +const maximumValidationName = "maximum" + +// RegisterMaximum registers the Maximum validation +// with the provided validation registry. +func RegisterMaximum(registry validations.Registry) { + registry.Register(maximumValidationName, maximumFactory) +} + +// maximumFactory is a function used to initialize a Maximum validation +// implementation based on the provided configuration. +func maximumFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Maximum{}, nil +} + +// Maximum is a Validation that can be used to identify +// incompatible changes to the maximum constraints of CRD properties. +type Maximum struct { + MaxOptions +} + +// Name returns the name of the Maximum validation. +func (m *Maximum) Name() string { + return maximumValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Maximum validation. +func (m *Maximum) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the maximum constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Maximum field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *Maximum) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MaxVerification(a.Maximum, b.Maximum) + + a.Maximum = nil + b.Maximum = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MaxItems)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MaxItems)(nil) +) + +const maxItemsValidationName = "maxItems" + +// RegisterMaxItems registers the MaxItems validation +// with the provided validation registry. +func RegisterMaxItems(registry validations.Registry) { + registry.Register(maxItemsValidationName, maxItemsFactory) +} + +// maxItemsFactory is a function used to initialize a MaxItems validation +// implementation based on the provided configuration. +func maxItemsFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MaxItems{}, nil +} + +// MaxItems is a Validation that can be used to identify +// incompatible changes to the maxItems constraints of CRD properties. +type MaxItems struct { + MaxOptions +} + +// Name returns the name of the MaxItems validation. +func (m *MaxItems) Name() string { + return maxItemsValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MaxItems validation. +func (m *MaxItems) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the maxItems constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MaxItems field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MaxItems) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MaxVerification(a.MaxItems, b.MaxItems) + + a.MaxItems = nil + b.MaxItems = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MaxLength)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MaxLength)(nil) +) + +const maxLengthValidationName = "maxLength" + +// RegisterMaxLength registers the MaxLength validation +// with the provided validation registry. +func RegisterMaxLength(registry validations.Registry) { + registry.Register(maxLengthValidationName, maxLengthFactory) +} + +// maxLengthFactory is a function used to initialize a MaxLength validation +// implementation based on the provided configuration. +func maxLengthFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MaxLength{}, nil +} + +// MaxLength is a Validation that can be used to identify +// incompatible changes to the maxLength constraints of CRD properties. +type MaxLength struct { + MaxOptions +} + +// Name returns the name of the MaxLength validation. +func (m *MaxLength) Name() string { + return maxLengthValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MaxLength validation. +func (m *MaxLength) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the maxLength constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MaxLength field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MaxLength) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MaxVerification(a.MaxLength, b.MaxLength) + + a.MaxLength = nil + b.MaxLength = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MaxProperties)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MaxProperties)(nil) +) + +const maxPropertiesValidationName = "maxProperties" + +// RegisterMaxProperties registers the MaxProperties validation +// with the provided validation registry. +func RegisterMaxProperties(registry validations.Registry) { + registry.Register(maxPropertiesValidationName, maxPropertiesFactory) +} + +// maxPropertiesFactory is a function used to initialize a MaxProperties validation +// implementation based on the provided configuration. +func maxPropertiesFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MaxProperties{}, nil +} + +// MaxProperties is a Validation that can be used to identify +// incompatible changes to the maxProperties constraints of CRD properties. +type MaxProperties struct { + MaxOptions +} + +// Name returns the name of the MaxProperties validation. +func (m *MaxProperties) Name() string { + return maxPropertiesValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MaxProperties validation. +func (m *MaxProperties) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the maxProperties constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MaxProperties field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MaxProperties) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MaxVerification(a.MaxProperties, b.MaxProperties) + + a.MaxProperties = nil + b.MaxProperties = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} diff --git a/internal/thirdparty/crdify/pkg/validations/property/min.go b/internal/thirdparty/crdify/pkg/validations/property/min.go new file mode 100644 index 000000000..1012d409c --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/min.go @@ -0,0 +1,253 @@ +// 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. + +//nolint:dupl +package property + +import ( + "cmp" + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// MinOptions is an abstraction for the common +// options for all the "Minimum" related constraints +// on CRD properties. +type MinOptions struct { + enforcement config.EnforcementPolicy +} + +// MinVerification is a generic helper function for comparing +// two cmp.Ordered values. It returns an error if: +// - older value is nil and newer value is not nil +// - older and newer values are not nil, newer is greater than older. +func MinVerification[T cmp.Ordered](older, newer *T) error { + var err error + + switch { + case older == nil && newer != nil: + err = fmt.Errorf("%w : %v", ErrNetNewMinimumConstraint, *newer) + case older != nil && newer != nil && *newer > *older: + err = fmt.Errorf("%w : %v -> %v", ErrMinimumIncreased, *older, *newer) + } + + return err +} + +var ( + // ErrNetNewMinimumConstraint represents an error state where a net new minimum constraint was added to a property. + ErrNetNewMinimumConstraint = errors.New("minimum constraint added when there was none previously") + // ErrMinimumIncreased represents an error state where a minimum constaint on a property was increased. + ErrMinimumIncreased = errors.New("minimum increased") +) + +var ( + _ validations.Validation = (*Minimum)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Minimum)(nil) +) + +const minimumValidationName = "minimum" + +// RegisterMinimum registers the Minimum validation +// with the provided validation registry. +func RegisterMinimum(registry validations.Registry) { + registry.Register(minimumValidationName, minimumFactory) +} + +// minimumFactory is a function used to initialize a Minimum validation +// implementation based on the provided configuration. +func minimumFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Minimum{}, nil +} + +// Minimum is a Validation that can be used to identify +// incompatible changes to the minimum constraints of CRD properties. +type Minimum struct { + MinOptions +} + +// Name returns the name of the Minimum validation. +func (m *Minimum) Name() string { + return minimumValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Minimum validation. +func (m *Minimum) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the minimum constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Minimum field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *Minimum) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MinVerification(a.Minimum, b.Minimum) + + a.Minimum = nil + b.Minimum = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MinItems)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MinItems)(nil) +) + +const minItemsValidationName = "minItems" + +// RegisterMinItems registers the MinItems validation +// with the provided validation registry. +func RegisterMinItems(registry validations.Registry) { + registry.Register(minItemsValidationName, minItemsFactory) +} + +// minItemsFactory is a function used to initialize a MinItems validation +// implementation based on the provided configuration. +func minItemsFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MinItems{}, nil +} + +// MinItems is a Validation that can be used to identify +// incompatible changes to the minItems constraints of CRD properties. +type MinItems struct { + MinOptions +} + +// Name returns the name of the MinItems validation. +func (m *MinItems) Name() string { + return minItemsValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MinItems validation. +func (m *MinItems) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the minItems constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MinItems field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MinItems) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MinVerification(a.MinItems, b.MinItems) + + a.MinItems = nil + b.MinItems = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MinLength)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MinLength)(nil) +) + +const minLengthValidationName = "minLength" + +// RegisterMinLength registers the MinLength validation +// with the provided validation registry. +func RegisterMinLength(registry validations.Registry) { + registry.Register(minLengthValidationName, minLengthFactory) +} + +// minLengthFactory is a function used to initialize a MinLength validation +// implementation based on the provided configuration. +func minLengthFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MinLength{}, nil +} + +// MinLength is a Validation that can be used to identify +// incompatible changes to the minLength constraints of CRD properties. +type MinLength struct { + MinOptions +} + +// Name returns the name of the MinLength validation. +func (m *MinLength) Name() string { + return minLengthValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MinLength validation. +func (m *MinLength) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the minLength constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MinLength field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MinLength) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MinVerification(a.MinLength, b.MinLength) + + a.MinLength = nil + b.MinLength = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} + +var ( + _ validations.Validation = (*MinProperties)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*MinProperties)(nil) +) + +const minPropertiesValidationName = "minProperties" + +// RegisterMinProperties registers the MinProperties validation +// with the provided validation registry. +func RegisterMinProperties(registry validations.Registry) { + registry.Register(minPropertiesValidationName, minPropertiesFactory) +} + +// minPropertiesFactory is a function used to initialize a MinProperties validation +// implementation based on the provided configuration. +func minPropertiesFactory(_ map[string]interface{}) (validations.Validation, error) { + return &MinProperties{}, nil +} + +// MinProperties is a Validation that can be used to identify +// incompatible changes to the minProperties constraints of CRD properties. +type MinProperties struct { + MinOptions +} + +// Name returns the name of the MinProperties validation. +func (m *MinProperties) Name() string { + return minPropertiesValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the MinProperties validation. +func (m *MinProperties) SetEnforcement(policy config.EnforcementPolicy) { + m.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the minProperties constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.MinProperties field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (m *MinProperties) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + err := MinVerification(a.MinProperties, b.MinProperties) + + a.MinProperties = nil + b.MinProperties = nil + + return validations.HandleErrors(m.Name(), m.enforcement, err) +} diff --git a/internal/thirdparty/crdify/pkg/validations/property/required.go b/internal/thirdparty/crdify/pkg/validations/property/required.go new file mode 100644 index 000000000..7eebf7dc2 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/required.go @@ -0,0 +1,85 @@ +// 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 property + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +var ( + _ validations.Validation = (*Required)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Required)(nil) +) + +const requiredValidationName = "required" + +// RegisterRequired registers the Required validation +// with the provided validation registry. +func RegisterRequired(registry validations.Registry) { + registry.Register(requiredValidationName, requiredFactory) +} + +// requiredFactory is a function used to initialize a Required validation +// implementation based on the provided configuration. +func requiredFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Required{}, nil +} + +// Required is a Validation that can be used to identify +// incompatible changes to the required constraints of CRD properties. +type Required struct { + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Required validation. +func (r *Required) Name() string { + return requiredValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Required validation. +func (r *Required) SetEnforcement(policy config.EnforcementPolicy) { + r.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the required constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Required field will be reset to 'nil' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (r *Required) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + oldRequired := sets.New(a.Required...) + newRequired := sets.New(b.Required...) + diffRequired := newRequired.Difference(oldRequired) + + var err error + + if diffRequired.Len() > 0 { + err = fmt.Errorf("%w: %v", ErrNewRequiredFields, diffRequired.UnsortedList()) + } + + a.Required = nil + b.Required = nil + + return validations.HandleErrors(r.Name(), r.enforcement, err) +} + +// ErrNewRequiredFields represents an error state where a property has new required fields. +var ErrNewRequiredFields = errors.New("new required fields") diff --git a/internal/thirdparty/crdify/pkg/validations/property/type.go b/internal/thirdparty/crdify/pkg/validations/property/type.go new file mode 100644 index 000000000..2f13a7657 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/type.go @@ -0,0 +1,79 @@ +// 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 property + +import ( + "errors" + "fmt" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +var ( + _ validations.Validation = (*Type)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Type)(nil) +) + +const typeValidationName = "type" + +// RegisterType registers the Type validation +// with the provided validation registry. +func RegisterType(registry validations.Registry) { + registry.Register(typeValidationName, typeFactory) +} + +// typeFactory is a function used to initialize a Type validation +// implementation based on the provided configuration. +func typeFactory(_ map[string]interface{}) (validations.Validation, error) { + return &Type{}, nil +} + +// Type is a Validation that can be used to identify +// incompatible changes to the type constraints of CRD properties. +type Type struct { + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Type validation. +func (t *Type) Name() string { + return typeValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Type validation. +func (t *Type) SetEnforcement(policy config.EnforcementPolicy) { + t.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the type constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Type field will be reset to '""' as part of this method. +// It is highly recommended that only copies of the JSONSchemaProps to compare are provided to this method +// to prevent unintentional modifications. +func (t *Type) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + var err error + if a.Type != b.Type { + err = fmt.Errorf("%w : %q -> %q", ErrTypeChanged, a.Type, b.Type) + } + + a.Type = "" + b.Type = "" + + return validations.HandleErrors(t.Name(), t.enforcement, err) +} + +// ErrTypeChanged represents an error state when a property type changed. +var ErrTypeChanged = errors.New("type changed") diff --git a/internal/thirdparty/crdify/pkg/validations/property/util.go b/internal/thirdparty/crdify/pkg/validations/property/util.go new file mode 100644 index 000000000..36b29b650 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/property/util.go @@ -0,0 +1,36 @@ +// 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 property + +import ( + "encoding/json" + "fmt" +) + +// ConfigToType is a utility function for unmarshalling unstructured data (map[string]interface{}) +// into a concrete type. +func ConfigToType[T any](in map[string]interface{}, out *T) error { + jsonBytes, err := json.Marshal(in) + if err != nil { + return fmt.Errorf("marshalling input to JSON: %w", err) + } + + err = json.Unmarshal(jsonBytes, out) + if err != nil { + return fmt.Errorf("unmarshalling input to output: %w", err) + } + + return nil +} diff --git a/internal/thirdparty/crdify/pkg/validations/registry.go b/internal/thirdparty/crdify/pkg/validations/registry.go new file mode 100644 index 000000000..7461c8c95 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/registry.go @@ -0,0 +1,132 @@ +// 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 validations + +import ( + "errors" + "fmt" + "sync" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Comparable is a generic interface that represents either a +// CustomResourceDefinition or a JSONSchemaProps. +type Comparable interface { + apiextensionsv1.CustomResourceDefinition | apiextensionsv1.JSONSchemaProps +} + +// Comparator is a generic interface for comparing two objects and getting back +// a ComparisonResult. +type Comparator[T Comparable] interface { + // Compare compares two instances of type T and returns a ComparisonResult + Compare(a, b *T) ComparisonResult +} + +// Validation is an interface to represent the minimal set of +// functionality that needs to be implemented by a validation. +type Validation interface { + // Name is the name of the validation + Name() string + // SetEnforcement sets the enforcement policy for the validation + SetEnforcement(policy config.EnforcementPolicy) +} + +// ComparisonResult contains the results of running a Comparator. +type ComparisonResult struct { + // Name is the name of the Comparator implementation that + // performed the comparison + Name string `json:"name"` + + // Errors is the set of errors encountered during comparison + Errors []string `json:"errors,omitempty"` + + // Warnings is the set of warnings encountered during comparison + Warnings []string `json:"warnings,omitempty"` +} + +// Factory is a function used for creating a Validation based on a +// provided configuration. Should return an error if the Validation +// cannot be successfully created with the provided configuration. +type Factory func(config map[string]interface{}) (Validation, error) + +// Registry is a registry for Validations. +type Registry interface { + // Register registers a name and how to create an instance of a Validation with that name + Register(name string, validation Factory) + + // Registered returns a list of the registered Validation names + Registered() []string + + // Validation returns a Validation for the provided name and configuration + Validation(name string, config map[string]interface{}) (Validation, error) +} + +// validationRegistry is an implementation of Registry. +type validationRegistry struct { + lock sync.Mutex + validations map[string]Factory +} + +// NewRegistry creates a new Registry. +func NewRegistry() Registry { + return &validationRegistry{ + lock: sync.Mutex{}, + validations: make(map[string]Factory), + } +} + +// Register registers a name and how to create an instance of a Validation with that name. +// If a validation has already been registered, this method will panic. +func (vr *validationRegistry) Register(name string, validation Factory) { + vr.lock.Lock() + defer vr.lock.Unlock() + + if _, ok := vr.validations[name]; ok { + panic(fmt.Sprintf("validation %q has already been registered", name)) + } + + vr.validations[name] = validation +} + +// Registered returns the set of registered validation names. +func (vr *validationRegistry) Registered() []string { + vr.lock.Lock() + defer vr.lock.Unlock() + + keys := []string{} + + for k := range vr.validations { + keys = append(keys, k) + } + + return keys +} + +// Validation creates the Validation for the provided validation name and configuration if it is registered. +func (vr *validationRegistry) Validation(name string, config map[string]interface{}) (Validation, error) { + vr.lock.Lock() + defer vr.lock.Unlock() + + factory, ok := vr.validations[name] + if !ok { + return nil, fmt.Errorf("%w : %q", errUnknownValidation, name) + } + + return factory(config) +} + +var errUnknownValidation = errors.New("unknown validation") diff --git a/internal/thirdparty/crdify/pkg/validations/util.go b/internal/thirdparty/crdify/pkg/validations/util.go new file mode 100644 index 000000000..6c5ece4d8 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validations/util.go @@ -0,0 +1,326 @@ +// 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 validations + +import ( + "errors" + "fmt" + "sync" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/slices" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// GetCRDVersionByName returns a CustomResourceDefinitionVersion with the provided name from the provided CustomResourceDefinition. +func GetCRDVersionByName(crd *apiextensionsv1.CustomResourceDefinition, name string) *apiextensionsv1.CustomResourceDefinitionVersion { + if crd == nil { + return nil + } + + for _, version := range crd.Spec.Versions { + if version.Name == name { + return &version + } + } + + return nil +} + +// FlattenCRDVersion flattens the provided CustomResourceDefinition into a mapping of +// property path (i.e ^.spec.foo.bar) to its JSONSchemaProps. +func FlattenCRDVersion(crdVersion apiextensionsv1.CustomResourceDefinitionVersion) map[string]*apiextensionsv1.JSONSchemaProps { + flatMap := map[string]*apiextensionsv1.JSONSchemaProps{} + + SchemaHas(crdVersion.Schema.OpenAPIV3Schema, + field.NewPath("^"), + field.NewPath("^"), + nil, + func(s *apiextensionsv1.JSONSchemaProps, _, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { + flatMap[simpleLocation.String()] = s.DeepCopy() + return false + }, + ) + + return flatMap +} + +// Diff is a utility struct for holding an old and new JSONSchemaProps. +type Diff struct { + Old *apiextensionsv1.JSONSchemaProps + New *apiextensionsv1.JSONSchemaProps +} + +// FlattenedCRDVersionDiff calculates differences between flattened CRD versions. +// Returns the set of differing properties as a map of the property path (i.e ^.spec.foo.bar) +// to the Diff (old and new JSONSchemaProps). +func FlattenedCRDVersionDiff(a, b map[string]*apiextensionsv1.JSONSchemaProps) map[string]Diff { + diffMap := map[string]Diff{} + + for prop, oldSchema := range a { + // Create a copy of the old schema and set the properties to nil. + // In theory this will make it so we don't provide a diff for a parent property + // based on changes to the children properties. The changes to the children + // properties should still be evaluated since we are looping through a flattened + // map of all the properties for the CRD version + oldSchemaCopy := DropChildrenPropertiesFromJSONSchema(oldSchema) + newSchema, ok := b[prop] + + // In the event the property no longer exists on the new version + // create a diff entry with the new value being empty + if !ok { + diffMap[prop] = Diff{Old: oldSchemaCopy, New: &apiextensionsv1.JSONSchemaProps{}} + // Continue as there is no newSchema to copy and evaluate for this prop. + continue + } + + // Do the same copy and unset logic for the new schema properties + // before comparison to ensure we are only comparing the individual properties + newSchemaCopy := DropChildrenPropertiesFromJSONSchema(newSchema) + + if !equality.Semantic.DeepEqual(oldSchemaCopy, newSchemaCopy) { + diffMap[prop] = Diff{Old: oldSchemaCopy, New: newSchemaCopy} + } + } + + return diffMap +} + +// DropChildrenPropertiesFromJSONSchema sets properties on a schema +// associated with children schemas to `nil`. Useful when calculating +// differences between a before and after of a given schema +// without the changes to its children schemas influencing the +// diff calculation. +// Returns a copy of the provided apiextensionsv1.JSONSchemaProps with children schemas dropped. +func DropChildrenPropertiesFromJSONSchema(schema *apiextensionsv1.JSONSchemaProps) *apiextensionsv1.JSONSchemaProps { + schemaCopy := schema.DeepCopy() + schemaCopy.Properties = nil + schemaCopy.Items = nil + + return schemaCopy +} + +// SchemaWalkerFunc is a function that walks a JSONSchemaProps. +// ancestry is an order list of ancestors of s, where index 0 is the root and index len-1 is the direct parent. +type SchemaWalkerFunc func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps) bool + +// SchemaHas recursively traverses the Schema and calls the `pred` +// predicate to see if the schema contains specific values. +// +// The predicate MUST NOT keep a copy of the json schema NOR modify the +// schema. +// +//nolint:gocognit,cyclop +func SchemaHas(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps, pred SchemaWalkerFunc) bool { + if s == nil { + return false + } + + if pred(s, fldPath, simpleLocation, ancestry) { + return true + } + + //nolint:gocritic + nextAncestry := append(ancestry, s) + + if s.Items != nil { + if s.Items != nil && schemaHasRecurse(s.Items.Schema, fldPath.Child("items"), simpleLocation.Key("*"), nextAncestry, pred) { + return true + } + + for i := range s.Items.JSONSchemas { + if schemaHasRecurse(&s.Items.JSONSchemas[i], fldPath.Child("items", "jsonSchemas").Index(i), simpleLocation.Index(i), nextAncestry, pred) { + return true + } + } + } + + for i := range s.AllOf { + if schemaHasRecurse(&s.AllOf[i], fldPath.Child("allOf").Index(i), simpleLocation, nextAncestry, pred) { + return true + } + } + + for i := range s.AnyOf { + if schemaHasRecurse(&s.AnyOf[i], fldPath.Child("anyOf").Index(i), simpleLocation, nextAncestry, pred) { + return true + } + } + + for i := range s.OneOf { + if schemaHasRecurse(&s.OneOf[i], fldPath.Child("oneOf").Index(i), simpleLocation, nextAncestry, pred) { + return true + } + } + + if schemaHasRecurse(s.Not, fldPath.Child("not"), simpleLocation, nextAncestry, pred) { + return true + } + + for propertyName, s := range s.Properties { + if schemaHasRecurse(&s, fldPath.Child("properties").Key(propertyName), simpleLocation.Child(propertyName), nextAncestry, pred) { + return true + } + } + + if s.AdditionalProperties != nil { + if schemaHasRecurse(s.AdditionalProperties.Schema, fldPath.Child("additionalProperties", "schema"), simpleLocation.Key("*"), nextAncestry, pred) { + return true + } + } + + for patternName, s := range s.PatternProperties { + if schemaHasRecurse(&s, fldPath.Child("allOf").Key(patternName), simpleLocation, nextAncestry, pred) { + return true + } + } + + if s.AdditionalItems != nil { + if schemaHasRecurse(s.AdditionalItems.Schema, fldPath.Child("additionalItems", "schema"), simpleLocation, nextAncestry, pred) { + return true + } + } + + for _, s := range s.Definitions { + if schemaHasRecurse(&s, fldPath.Child("definitions"), simpleLocation, nextAncestry, pred) { + return true + } + } + + for dependencyName, d := range s.Dependencies { + if schemaHasRecurse(d.Schema, fldPath.Child("dependencies").Key(dependencyName).Child("schema"), simpleLocation, nextAncestry, pred) { + return true + } + } + + return false +} + +//nolint:gochecknoglobals +var schemaPool = sync.Pool{ + New: func() any { + return new(apiextensionsv1.JSONSchemaProps) + }, +} + +func schemaHasRecurse(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps, pred SchemaWalkerFunc) bool { + if s == nil { + return false + } + + schema, ok := schemaPool.Get().(*apiextensionsv1.JSONSchemaProps) + if !ok { + return false + } + defer schemaPool.Put(schema) + + *schema = *s + + return SchemaHas(schema, fldPath, simpleLocation, ancestry, pred) +} + +// ComparatorsForValidations extracts the Comparators of type T from the provided set of Validations. +func ComparatorsForValidations[T Comparable](vals ...Validation) []Comparator[T] { + comparators := []Comparator[T]{} + + for _, val := range vals { + comp, ok := val.(Comparator[T]) + if !ok { + continue + } + + comparators = append(comparators, comp) + } + + return comparators +} + +// LoadValidationsFromRegistry initializes all validations registered with the provided +// registry using an empty configuration. +// It returns the validations in a map to make it easier to update the Validations in one-shot operations. +// Any errors encountered during the initialization process are aggregated and returned as a single error. +func LoadValidationsFromRegistry(registry Registry) (map[string]Validation, error) { + vals := map[string]Validation{} + errs := []error{} + + for _, validation := range registry.Registered() { + val, err := registry.Validation(validation, make(map[string]interface{})) + if err != nil { + errs = append(errs, fmt.Errorf("initializing validation %q: %w", validation, err)) + } + + val.SetEnforcement(config.EnforcementPolicyError) + + vals[validation] = val + } + + return vals, errors.Join(errs...) +} + +// ConfigureValidations is a utility function for configuring the provided set of validations +// using the provided registyr and configuration. +// It returns a copy of the original validations mapping with validations that had specific +// configurations replaced with a newly initialized validation. +// Any errors encountered during the initialization process are aggregated and returned as a single error. +func ConfigureValidations(validations map[string]Validation, registry Registry, cfg config.Config) (map[string]Validation, error) { + modified := validations + errs := []error{} + + for _, validation := range cfg.Validations { + val, err := registry.Validation(validation.Name, validation.Configuration) + if err != nil { + errs = append(errs, fmt.Errorf("configuring validation %q: %w", validation.Name, err)) + continue + } + + switch validation.Enforcement { + case config.EnforcementPolicyError, config.EnforcementPolicyWarn, config.EnforcementPolicyNone: + val.SetEnforcement(validation.Enforcement) + default: + errs = append(errs, fmt.Errorf("configuring validation %q: %w : %q", validation.Name, errUnknownEnforcementPolicy, validation.Enforcement)) + } + + modified[validation.Name] = val + } + + return modified, errors.Join(errs...) +} + +var errUnknownEnforcementPolicy = errors.New("unknown enforcement policy") + +// HandleErrors is a utility function for Comparators to generate a ComparisonResult +// based on the provided Comparator name, enforcement policy, and any errors it encountered. +func HandleErrors(name string, policy config.EnforcementPolicy, errs ...error) ComparisonResult { + result := ComparisonResult{ + Name: name, + } + + switch policy { + case config.EnforcementPolicyError: + if errors.Join(errs...) != nil { + result.Errors = slices.Translate(func(err error) string { return err.Error() }, errs...) + } + case config.EnforcementPolicyWarn: + if errors.Join(errs...) != nil { + result.Warnings = slices.Translate(func(err error) string { return err.Error() }, errs...) + } + case config.EnforcementPolicyNone: + return result + } + + return result +} diff --git a/internal/thirdparty/crdify/pkg/validators/crd/crd.go b/internal/thirdparty/crdify/pkg/validators/crd/crd.go new file mode 100644 index 000000000..481ae5e07 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validators/crd/crd.go @@ -0,0 +1,62 @@ +// 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 crd + +import ( + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Validator validates Kubernetes CustomResourceDefinitions using the configured validations. +type Validator struct { + comparators []validations.Comparator[apiextensionsv1.CustomResourceDefinition] +} + +// ValidatorOption configures a Validator. +type ValidatorOption func(*Validator) + +// WithComparators configures a Validator with the provided CustomResourceDefinition Comparators. +// Each call to WithComparators is a replacement, not additive. +func WithComparators(comparators ...validations.Comparator[apiextensionsv1.CustomResourceDefinition]) ValidatorOption { + return func(v *Validator) { + v.comparators = comparators + } +} + +// New returns a new Validator for validating an old and new CustomResourceDefinition +// configured with the provided ValidatorOptions. +func New(opts ...ValidatorOption) *Validator { + validator := &Validator{ + comparators: []validations.Comparator[apiextensionsv1.CustomResourceDefinition]{}, + } + + for _, opt := range opts { + opt(validator) + } + + return validator +} + +// Validate runs the validations configured in the Validator. +func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) []validations.ComparisonResult { + result := []validations.ComparisonResult{} + + for _, comparator := range v.comparators { + compResult := comparator.Compare(a, b) + result = append(result, compResult) + } + + return result +} diff --git a/internal/thirdparty/crdify/pkg/validators/version/same/same.go b/internal/thirdparty/crdify/pkg/validators/version/same/same.go new file mode 100644 index 000000000..a832bf530 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validators/version/same/same.go @@ -0,0 +1,86 @@ +// 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 same + +import ( + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +// Validator validates Kubernetes CustomResourceDefinitions using the configured validations. +type Validator struct { + comparators []validations.Comparator[apiextensionsv1.JSONSchemaProps] + unhandledEnforcement config.EnforcementPolicy +} + +// ValidatorOption configures a Validator. +type ValidatorOption func(*Validator) + +// WithComparators configures a Validator with the provided JSONSchemaProps Comparators. +// Each call to WithComparators is a replacement, not additive. +func WithComparators(comparators ...validations.Comparator[apiextensionsv1.JSONSchemaProps]) ValidatorOption { + return func(v *Validator) { + v.comparators = comparators + } +} + +// WithUnhandledEnforcementPolicy sets the unhandled enforcement policy for the validator. +func WithUnhandledEnforcementPolicy(policy config.EnforcementPolicy) ValidatorOption { + return func(v *Validator) { + if policy == "" { + policy = config.EnforcementPolicyError + } + + v.unhandledEnforcement = policy + } +} + +// New creates a new Validator to validate the same versions of an old and new CustomResourceDefinition +// configured with the provided ValidatorOptions. +func New(opts ...ValidatorOption) *Validator { + validator := &Validator{ + comparators: []validations.Comparator[apiextensionsv1.JSONSchemaProps]{}, + unhandledEnforcement: config.EnforcementPolicyError, + } + + for _, opt := range opts { + opt(validator) + } + + return validator +} + +// Validate runs the validations configured in the Validator. +func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) map[string]map[string][]validations.ComparisonResult { + result := map[string]map[string][]validations.ComparisonResult{} + + for _, oldVersion := range a.Spec.Versions { + newVersion := validations.GetCRDVersionByName(b, oldVersion.Name) + // in this case, there is nothing to compare. Generally, the removal + // of an existing version is a breaking change. It may be considered safe + // if there are no CRs stored at that version or migration has successfully + // occurred. Since the safety of this varies and we don't have explicit + // knowledge of this we assume a separate check will be in place to capture + // this as a breaking change. + if newVersion == nil { + continue + } + + result[oldVersion.Name] = validations.CompareVersions(*oldVersion.DeepCopy(), *newVersion.DeepCopy(), v.unhandledEnforcement, v.comparators...) + } + + return result +} diff --git a/internal/thirdparty/crdify/pkg/validators/version/served/served.go b/internal/thirdparty/crdify/pkg/validators/version/served/served.go new file mode 100644 index 000000000..87ad20c19 --- /dev/null +++ b/internal/thirdparty/crdify/pkg/validators/version/served/served.go @@ -0,0 +1,205 @@ +// 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 served + +import ( + "fmt" + "slices" + + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/config" + "github.com/operator-framework/operator-controller/internal/thirdparty/crdify/pkg/validations" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + versionhelper "k8s.io/apimachinery/pkg/version" +) + +// Validator validates Kubernetes CustomResourceDefinitions using the configured validations. +type Validator struct { + comparators []validations.Comparator[apiextensionsv1.JSONSchemaProps] + conversionPolicy config.ConversionPolicy + unhandledEnforcement config.EnforcementPolicy +} + +// ValidatorOption configures a Validator. +type ValidatorOption func(*Validator) + +// WithComparators configures a Validator with the provided JSONSchemaProps Comparators. +// Each call to WithComparators is a replacement, not additive. +func WithComparators(comparators ...validations.Comparator[apiextensionsv1.JSONSchemaProps]) ValidatorOption { + return func(v *Validator) { + v.comparators = comparators + } +} + +// WithUnhandledEnforcementPolicy sets the unhandled enforcement policy for the validator. +func WithUnhandledEnforcementPolicy(policy config.EnforcementPolicy) ValidatorOption { + return func(v *Validator) { + if policy == "" { + policy = config.EnforcementPolicyError + } + + v.unhandledEnforcement = policy + } +} + +// WithConversionPolicy sets the conversion policy for the validator. +func WithConversionPolicy(policy config.ConversionPolicy) ValidatorOption { + return func(v *Validator) { + if policy == "" { + policy = config.ConversionPolicyNone + } + + v.conversionPolicy = policy + } +} + +// New creates a new Validator to validate the served versions of an old and new CustomResourceDefinition +// configured with the provided ValidatorOptions. +func New(opts ...ValidatorOption) *Validator { + validator := &Validator{ + comparators: []validations.Comparator[apiextensionsv1.JSONSchemaProps]{}, + conversionPolicy: config.ConversionPolicyNone, + unhandledEnforcement: config.EnforcementPolicyError, + } + + for _, opt := range opts { + opt(validator) + } + + return validator +} + +// Validate runs the validations configured in the Validator. +func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) map[string]map[string][]validations.ComparisonResult { + result := map[string]map[string][]validations.ComparisonResult{} + + // If conversion webhook is specified and conversion policy is ignore, pass check + if v.conversionPolicy == config.ConversionPolicyIgnore && b.Spec.Conversion != nil && b.Spec.Conversion.Strategy == apiextensionsv1.WebhookConverter { + return result + } + + aResults := v.compareVersionPairs(a) + bResults := v.compareVersionPairs(b) + subtractExistingIssues(bResults, aResults) + + return bResults +} + +func (v *Validator) compareVersionPairs(crd *apiextensionsv1.CustomResourceDefinition) map[string]map[string][]validations.ComparisonResult { + result := map[string]map[string][]validations.ComparisonResult{} + + for resultVersionPair, versions := range makeVersionPairs(crd) { + result[resultVersionPair] = validations.CompareVersions(versions[0], versions[1], v.unhandledEnforcement, v.comparators...) + } + + return result +} + +func makeVersionPairs(crd *apiextensionsv1.CustomResourceDefinition) map[string][2]apiextensionsv1.CustomResourceDefinitionVersion { + servedVersions := make([]apiextensionsv1.CustomResourceDefinitionVersion, 0, len(crd.Spec.Versions)) + + for _, version := range crd.Spec.Versions { + if version.Served { + servedVersions = append(servedVersions, version) + } + } + + if len(servedVersions) < 2 { + return nil + } + + slices.SortFunc(servedVersions, func(a, b apiextensionsv1.CustomResourceDefinitionVersion) int { + return versionhelper.CompareKubeAwareVersionStrings(a.Name, b.Name) + }) + + pairs := make(map[string][2]apiextensionsv1.CustomResourceDefinitionVersion, numUnidirectionalPermutations(servedVersions)) + + for i, iVersion := range servedVersions[:len(servedVersions)-1] { + for _, jVersion := range servedVersions[i+1:] { + resultVersionPair := fmt.Sprintf("%s -> %s", iVersion.Name, jVersion.Name) + pairs[resultVersionPair] = [2]apiextensionsv1.CustomResourceDefinitionVersion{iVersion, jVersion} + } + } + + return pairs +} + +func numUnidirectionalPermutations[T any](in []T) int { + n := len(in) + + return (n * (n - 1)) / 2 +} + +// subtractExistingIssues removes errors and warnings from b's results that are also found in a's results. +func subtractExistingIssues(b, a map[string]map[string][]validations.ComparisonResult) { + sliceToMapByName := func(in []validations.ComparisonResult) map[string]*validations.ComparisonResult { + out := make(map[string]*validations.ComparisonResult, len(in)) + + for i := range in { + v := &in[i] + out[v.Name] = v + } + + return out + } + + for versionPair, bVersionPairResults := range b { + aVersionPairResults, ok := a[versionPair] + if !ok { + // If the version pair is not found in a, that means + // b introduced a new version, so we'll keep _all_ + // of the comparison results for this pair + continue + } + + for fieldPath, bFieldPathResults := range bVersionPairResults { + aFieldPathResults, ok := aVersionPairResults[fieldPath] + if !ok { + // If this field path is not found in a's results + // for this version pair, that means b introduced a new field + // in an existing schema, so we'll keep _all_ of the comparison + // results for this field path. + continue + } + + aResultMap := sliceToMapByName(aFieldPathResults) + bResultMap := sliceToMapByName(bFieldPathResults) + + for validationName, bValidationResult := range bResultMap { + aValidationResult, ok := aResultMap[validationName] + if !ok { + // If a's results do not include results for this validation, + // that means we ran a new validation for b that we did not + // run for a. We never intend to do that, so if that is somehow + // the case, let's panic and say what our programmer intent was. + panic(fmt.Sprintf("Validation %q not found in a's results for version pair %q. This should never happen because this validator uses the same validation configuration for CRDs a and b.", validationName, versionPair)) + } + + bValidationResult.Errors = slices.DeleteFunc(bValidationResult.Errors, func(bErr string) bool { + return slices.Contains(aValidationResult.Errors, bErr) + }) + if len(bValidationResult.Errors) == 0 { + bValidationResult.Errors = nil + } + + bValidationResult.Warnings = slices.DeleteFunc(bValidationResult.Warnings, func(bWarn string) bool { + return slices.Contains(aValidationResult.Warnings, bWarn) + }) + if len(bValidationResult.Warnings) == 0 { + bValidationResult.Warnings = nil + } + } + } + } +} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_lists_must_have_ssa_tags.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_lists_must_have_ssa_tags.go deleted file mode 100644 index 96ce9d3ff..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_lists_must_have_ssa_tags.go +++ /dev/null @@ -1,60 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type listsMustHaveSSATags struct{} - -func ListsMustHaveSSATags() CRDComparator { - return listsMustHaveSSATags{} -} - -func (listsMustHaveSSATags) Name() string { - return "ListsMustHaveSSATags" -} - -func (listsMustHaveSSATags) WhyItMatters() string { - return "Lists require x-kubernetes-list-type tags in order to properly merge different requests from different field managers. " + - "Valid value are 'atomic', 'set', and 'map' and are indicated in kubebuilder tags with '// +listType=' and " + - "'// +listMapKey='." -} - -func (b listsMustHaveSSATags) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - fieldsWithoutListType := []string{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if s.Type != "array" { - return false - } - if s.XListType == nil || len(*s.XListType) == 0 { - fieldsWithoutListType = append(fieldsWithoutListType, simpleLocation.String()) - } - return false - }) - - for _, newMapField := range fieldsWithoutListType { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v must set x-kubernetes-list-type", crd.Name, newVersion.Name, newMapField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (b listsMustHaveSSATags) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(b, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_must_have_status.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_must_have_status.go deleted file mode 100644 index d6eba6576..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_must_have_status.go +++ /dev/null @@ -1,62 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type mustHaveStatus struct{} - -func MustHaveStatus() CRDComparator { - return mustHaveStatus{} -} - -func (mustHaveStatus) Name() string { - return "MustHaveStatus" -} - -func (mustHaveStatus) WhyItMatters() string { - return "When the schema has a status field, it should be controlled via a status suberesource for different permissions " + - "to control those who can control desired state from those who can control the actual state." -} - -func (b mustHaveStatus) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - const statusField = "^.status" - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - if newVersion.Subresources != nil && newVersion.Subresources.Status != nil { - continue - } - - hasStatus := false - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if simpleLocation.String() == statusField { - hasStatus = true - return true - } - return false - }) - - if hasStatus { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v must have a status subresource in .spec.version[name=%v].subresources.status to match its schema.", crd.Name, newVersion.Name, statusField, newVersion.Name)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (b mustHaveStatus) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(b, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_bools.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_bools.go deleted file mode 100644 index 1be7ea9ff..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_bools.go +++ /dev/null @@ -1,58 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noBools struct{} - -func NoBools() CRDComparator { - return noBools{} -} - -func (noBools) Name() string { - return "NoBools" -} - -func (noBools) WhyItMatters() string { - return "Booleans rarely stay booleans and can never develop new options. This frequently leads to cases where there " + - "are multiple boolean fields, with some combinations of values not being allowed. Additionally, strings provide " + - "expressive names and values, describing degrees or conditions of a thing. Also, booleans cannot be defaulted, " + - "pointers to booleans can be, but at that point you've already got a tri-state, so it's not a boolean is it..." -} - -func (b noBools) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - newBoolFields := []string{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if s.Type == "boolean" { - newBoolFields = append(newBoolFields, simpleLocation.String()) - } - return false - }) - - for _, newBoolField := range newBoolFields { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v may not be a boolean", crd.Name, newVersion.Name, newBoolField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (b noBools) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(b, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_enum_removal.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_enum_removal.go deleted file mode 100644 index 632f0147f..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_enum_removal.go +++ /dev/null @@ -1,85 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noEnumRemoval struct{} - -func NoEnumRemoval() CRDComparator { - return noEnumRemoval{} -} - -func (noEnumRemoval) Name() string { - return "NoEnumRemoval" -} - -func (noEnumRemoval) WhyItMatters() string { - return "If enums are removed, then clients that use those enum values will not be able to upgrade to the newest CRD." -} - -func getEnums(version *apiextensionsv1.CustomResourceDefinitionVersion) map[string]sets.String { - enumsMap := make(map[string]sets.String) - SchemaHas(version.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - for _, enum := range s.Enum { - _, exists := enumsMap[simpleLocation.String()] - if !exists { - enumsMap[simpleLocation.String()] = sets.NewString() - } - enumsMap[simpleLocation.String()].Insert(string(enum.Raw)) - } - return false - }) - - return enumsMap -} - -func (b noEnumRemoval) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - if existingCRD == nil { - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: nil, - Warnings: nil, - Infos: nil, - }, nil - } - errsToReport := []string{} - - for _, newVersion := range newCRD.Spec.Versions { - - existingVersion := GetVersionByName(existingCRD, newVersion.Name) - if existingVersion == nil { - continue - } - - existingEnumsMap := getEnums(existingVersion) - newEnumsMap := getEnums(&newVersion) - - for field, existingEnums := range existingEnumsMap { - newEnums, exists := newEnumsMap[field] - if exists { - removedEnums := existingEnums.Difference(newEnums) - for _, removedEnum := range removedEnums.List() { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v enum/%v may not be removed for field/%v", newCRD.Name, newVersion.Name, removedEnum, field)) - } - } - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_field_removal.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_field_removal.go deleted file mode 100644 index d345809bd..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_field_removal.go +++ /dev/null @@ -1,74 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noFieldRemoval struct{} - -func NoFieldRemoval() CRDComparator { - return noFieldRemoval{} -} - -func (noFieldRemoval) Name() string { - return "NoFieldRemoval" -} - -func (noFieldRemoval) WhyItMatters() string { - return "If fields are removed, then clients that rely on those fields will not be able to read them or write them." -} - -func getFields(version *apiextensionsv1.CustomResourceDefinitionVersion) sets.String { - fields := sets.NewString() - SchemaHas(version.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - fields.Insert(simpleLocation.String()) - return false - }) - - return fields -} - -func (b noFieldRemoval) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - if existingCRD == nil { - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: nil, - Warnings: nil, - Infos: nil, - }, nil - } - errsToReport := []string{} - - for _, newVersion := range newCRD.Spec.Versions { - - existingVersion := GetVersionByName(existingCRD, newVersion.Name) - if existingVersion == nil { - continue - } - - existingFields := getFields(existingVersion) - newFields := getFields(&newVersion) - - removedFields := existingFields.Difference(newFields) - for _, removedField := range removedFields.List() { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v may not be removed", newCRD.Name, newVersion.Name, removedField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_floats.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_floats.go deleted file mode 100644 index fe2b8a6a4..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_floats.go +++ /dev/null @@ -1,56 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noFloats struct{} - -func NoFloats() CRDComparator { - return noFloats{} -} - -func (noFloats) Name() string { - return "NoFloats" -} - -func (noFloats) WhyItMatters() string { - return "Floating-point values cannot be reliably round-tripped (encoded and re-decoded) without changing, " + - "and have varying precision and representations across languages and architectures." -} - -func (b noFloats) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - newFloatFields := []string{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if s.Type == "number" { - newFloatFields = append(newFloatFields, simpleLocation.String()) - } - return false - }) - - for _, newFloatField := range newFloatFields { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v may not be a float", crd.Name, newVersion.Name, newFloatField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (b noFloats) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(b, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_maps.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_maps.go deleted file mode 100644 index f87d4f34a..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_maps.go +++ /dev/null @@ -1,62 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noMaps struct{} - -func NoMaps() CRDComparator { - return noMaps{} -} - -func (noMaps) Name() string { - return "NoMaps" -} - -func (noMaps) WhyItMatters() string { - return "When serialized into yaml or json, maps don't have \"names\" associated with their key. This makes " + - "it less obvious what the key of map means or what is for. Additionally, maps are not guaranteed stable " + - "for serialization, but lists are always ordered. Instead of maps, use lists with a field that functions as " + - "a key and use a listMapKey marker for server-side-apply." -} - -func (b noMaps) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - newMapFields := []string{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if s.Type == "object" { - // I think this is how openapi v3 marks maps: https://swagger.io/docs/specification/data-models/dictionaries/ - // "normal" objects appear to use properties, not additionalProperties. - if s.AdditionalProperties != nil { - newMapFields = append(newMapFields, simpleLocation.String()) - } - } - return false - }) - - for _, newMapField := range newMapFields { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v may not be a map", crd.Name, newVersion.Name, newMapField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (b noMaps) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(b, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_new_required_fields.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_new_required_fields.go deleted file mode 100644 index 9a21915fa..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_new_required_fields.go +++ /dev/null @@ -1,168 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - "strings" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noNewRequiredFields struct{} - -func NoNewRequiredFields() CRDComparator { - return noNewRequiredFields{} -} - -func (noNewRequiredFields) Name() string { - return "NoNewRequiredFields" -} - -func (noNewRequiredFields) WhyItMatters() string { - return "If new fields are required, then old clients will not function properly. Even if CRD defaulting is used, " + - "CRD defaulting requires allowing an object with an empty or missing value to then get defaulted." -} - -func (b noNewRequiredFields) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - if existingCRD == nil { - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: nil, - Warnings: nil, - Infos: nil, - }, nil - } - errsToReport := []string{} - - for _, newVersion := range newCRD.Spec.Versions { - - existingVersion := GetVersionByName(existingCRD, newVersion.Name) - if existingVersion == nil { - continue - } - - existingRequiredFields := map[string]sets.String{} - existingSimpleLocationToJSONSchemaProps := map[string]*apiextensionsv1.JSONSchemaProps{} - SchemaHas(existingVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - existingRequiredFields[simpleLocation.String()] = sets.NewString(s.Required...) - existingSimpleLocationToJSONSchemaProps[simpleLocation.String()] = s - return false - }) - - // New fields can be required if they are wrapped inside new structs that are themselves optional. - // For instance, you cannot add .spec.thingy as required, but if you add .spec.top as optional and at the same - // time add .spec.top.thingy as required, this is allowed. - // Similar logic exists for adding an array with minlength > 0 - newRequiredFields := sets.NewString() - newSimpleLocationToRequiredFields := map[string]sets.String{} - newToSimpleLocation := map[*apiextensionsv1.JSONSchemaProps]*field.Path{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestors []*apiextensionsv1.JSONSchemaProps) bool { - newSimpleLocationToRequiredFields[simpleLocation.String()] = sets.NewString(s.Required...) - newToSimpleLocation[s] = simpleLocation - - if s.Type == "array" { - // if it's an array, we have a different property to check. A new array cannot be required unless it's ancestor is new. - if s.MinLength == nil || *s.MinLength == 0 { - // if there is no required length, this is fine - return false - } - // this means we're an array with a minLength, check to see if any parent wrapper is both new and optional. - if isAnyAncestorNewAndNullable(ancestors, existingSimpleLocationToJSONSchemaProps, newToSimpleLocation, newSimpleLocationToRequiredFields) { - return false - } - - // if we search all ancestors and couldn't find a new, optional element, then the current array cannot - // have a minLength greater than zero. - newRequiredFields.Insert(fmt.Sprintf("%s", simpleLocation.String())) - return false - } - - if len(s.Required) == 0 { - // if nothing is required, nothing to check. - return false - } - - existingRequired, existedBefore := existingRequiredFields[simpleLocation.String()] - if !existedBefore && s.Nullable { - // if the parent of the required field (current element) didn't exist in the schema before AND - // if the parent of the required field is nullable (client doesn't have to set it), - // then we can allow a child to be required. - return false - } - - if isAnyAncestorNewAndNullable(ancestors, existingSimpleLocationToJSONSchemaProps, newToSimpleLocation, newSimpleLocationToRequiredFields) { - // if any ancestor of the parent of the required field is new and nullable, then required is allowed. - return false - } - - // this covers newly required fields. - newRequired := sets.NewString(s.Required...) - if disallowedRequired := newRequired.Difference(existingRequired); len(disallowedRequired) > 0 { - for _, curr := range disallowedRequired.List() { - newRequiredFields.Insert(fmt.Sprintf("%s.%s", simpleLocation.String(), curr)) - } - return false - } - - return false - }) - - for _, newRequiredField := range newRequiredFields.List() { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v is new and may not be required", newCRD.Name, newVersion.Name, newRequiredField)) - } - - } - - return ComparisonResults{ - Name: b.Name(), - WhyItMatters: b.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func isAnyAncestorNewAndNullable( - ancestors []*apiextensionsv1.JSONSchemaProps, - existingSimpleLocationToJSONSchemaProps map[string]*apiextensionsv1.JSONSchemaProps, - newToSimpleLocation map[*apiextensionsv1.JSONSchemaProps]*field.Path, - newSimpleLocationToRequiredFields map[string]sets.String) bool { - - for i := len(ancestors) - 1; i >= 0; i-- { - ancestor := ancestors[i] - ancestorSimpleName := newToSimpleLocation[ancestor] - isOptionalArray := ancestor.Type == "array" && (ancestor.MinLength == nil || *ancestor.MinLength == 0) - isAncestoryOptional := ancestor.Nullable || isOptionalArray - if !isAncestoryOptional { - // if this ancestor isn't nullable, then it cannot allow the current element to be required - continue - } - - if _, existed := existingSimpleLocationToJSONSchemaProps[ancestorSimpleName.String()]; existed { - // if this ancestor previously existed, then it cannot allow the current element to be required - continue - } - if i == 0 { - // if the current accessor is the top level and Nullable, then it isn't required - return true - } - - // does the current ancestor require - parentOfAncestor := ancestors[i-1] - tokens := strings.Split(ancestorSimpleName.String(), ".") - lastStep := tokens[len(tokens)-1] - prevAncestorRequiredFields := newSimpleLocationToRequiredFields[newToSimpleLocation[parentOfAncestor].String()] - if !prevAncestorRequiredFields.Has(lastStep) { - // the current ancestor is not required, then we're ok and don't need to search further - return true - } - } - - return false -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_uints.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_uints.go deleted file mode 100644 index d3b0e1482..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/comp_no_uints.go +++ /dev/null @@ -1,55 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/validation/field" -) - -type noUints struct{} - -func NoUints() CRDComparator { - return noUints{} -} - -func (noUints) Name() string { - return "NoUints" -} - -func (noUints) WhyItMatters() string { - return "Unsigned integers don't have consistent support across languages and libraries." -} - -func (n noUints) Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - errsToReport := []string{} - - for _, newVersion := range crd.Spec.Versions { - uintFields := []string{} - SchemaHas(newVersion.Schema.OpenAPIV3Schema, field.NewPath("^"), field.NewPath("^"), nil, - func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, _ []*apiextensionsv1.JSONSchemaProps) bool { - if s.Format == "uint" { - uintFields = append(uintFields, simpleLocation.String()) - } - return false - }) - - for _, newUintField := range uintFields { - errsToReport = append(errsToReport, fmt.Sprintf("crd/%v version/%v field/%v may not be a uint", crd.Name, newVersion.Name, newUintField)) - } - - } - - return ComparisonResults{ - Name: n.Name(), - WhyItMatters: n.WhyItMatters(), - - Errors: errsToReport, - Warnings: nil, - Infos: nil, - }, nil -} - -func (n noUints) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - return RatchetCompare(n, existingCRD, newCRD) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/helpers.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/helpers.go deleted file mode 100644 index 3f34711c2..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/helpers.go +++ /dev/null @@ -1,121 +0,0 @@ -package manifestcomparators - -import ( - "sync" - - "k8s.io/apimachinery/pkg/util/validation/field" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -// GetVersionByName can be nil if the version doesn't exist -func GetVersionByName(crd *apiextensionsv1.CustomResourceDefinition, versionName string) *apiextensionsv1.CustomResourceDefinitionVersion { - if crd == nil { - return nil - } - - for i := range crd.Spec.Versions { - if crd.Spec.Versions[i].Name == versionName { - return &crd.Spec.Versions[i] - } - } - - return nil -} - -// ancestry is an order list of ancestors of s, where index 0 is the root and index len-1 is the direct parent -type SchemaWalkerFunc func(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps) bool - -// SchemaHas recursively traverses the Schema and calls the `pred` -// predicate to see if the schema contains specific values. -// -// The predicate MUST NOT keep a copy of the json schema NOR modify the -// schema. -func SchemaHas(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps, pred SchemaWalkerFunc) bool { - if s == nil { - return false - } - - if pred(s, fldPath, simpleLocation, ancestry) { - return true - } - - nextAncestry := append(ancestry, s) - - if s.Items != nil { - if s.Items != nil && schemaHasRecurse(s.Items.Schema, fldPath.Child("items"), simpleLocation.Key("*"), nextAncestry, pred) { - return true - } - for i := range s.Items.JSONSchemas { - if schemaHasRecurse(&s.Items.JSONSchemas[i], fldPath.Child("items", "jsonSchemas").Index(i), simpleLocation.Index(i), nextAncestry, pred) { - return true - } - } - } - for i := range s.AllOf { - if schemaHasRecurse(&s.AllOf[i], fldPath.Child("allOf").Index(i), simpleLocation, nextAncestry, pred) { - return true - } - } - for i := range s.AnyOf { - if schemaHasRecurse(&s.AnyOf[i], fldPath.Child("anyOf").Index(i), simpleLocation, nextAncestry, pred) { - return true - } - } - for i := range s.OneOf { - if schemaHasRecurse(&s.OneOf[i], fldPath.Child("oneOf").Index(i), simpleLocation, nextAncestry, pred) { - return true - } - } - if schemaHasRecurse(s.Not, fldPath.Child("not"), simpleLocation, nextAncestry, pred) { - return true - } - for propertyName, s := range s.Properties { - if schemaHasRecurse(&s, fldPath.Child("properties").Key(propertyName), simpleLocation.Child(propertyName), nextAncestry, pred) { - return true - } - } - if s.AdditionalProperties != nil { - if schemaHasRecurse(s.AdditionalProperties.Schema, fldPath.Child("additionalProperties", "schema"), simpleLocation.Key("*"), nextAncestry, pred) { - return true - } - } - for patternName, s := range s.PatternProperties { - if schemaHasRecurse(&s, fldPath.Child("allOf").Key(patternName), simpleLocation, nextAncestry, pred) { - return true - } - } - if s.AdditionalItems != nil { - if schemaHasRecurse(s.AdditionalItems.Schema, fldPath.Child("additionalItems", "schema"), simpleLocation, nextAncestry, pred) { - return true - } - } - for _, s := range s.Definitions { - if schemaHasRecurse(&s, fldPath.Child("definitions"), simpleLocation, nextAncestry, pred) { - return true - } - } - for dependencyName, d := range s.Dependencies { - if schemaHasRecurse(d.Schema, fldPath.Child("dependencies").Key(dependencyName).Child("schema"), simpleLocation, nextAncestry, pred) { - return true - } - } - - return false -} - -var schemaPool = sync.Pool{ - New: func() any { - return new(apiextensionsv1.JSONSchemaProps) - }, -} - -func schemaHasRecurse(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *field.Path, ancestry []*apiextensionsv1.JSONSchemaProps, pred SchemaWalkerFunc) bool { - if s == nil { - return false - } - schema := schemaPool.Get().(*apiextensionsv1.JSONSchemaProps) - defer schemaPool.Put(schema) - *schema = *s - return SchemaHas(schema, fldPath, simpleLocation, ancestry, pred) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/interfaces.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/interfaces.go deleted file mode 100644 index 4cc10424d..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/interfaces.go +++ /dev/null @@ -1,32 +0,0 @@ -package manifestcomparators - -import apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - -type ComparisonResults struct { - Name string `yaml:"name"` - WhyItMatters string `yaml:"whyItMatters"` - - Errors []string `yaml:"errors"` - Warnings []string `yaml:"warnings"` - Infos []string `yaml:"infos"` -} - -type CRDComparator interface { - Name() string - WhyItMatters() string - Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) -} - -type SingleCRDValidator interface { - Validate(crd *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) -} - -type CRDComparatorRegistry interface { - AddComparator(comparator CRDComparator) error - GetComparator(name string) (CRDComparator, error) - - KnownComparators() []string - AllComparators() []CRDComparator - - Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition, names ...string) ([]ComparisonResults, []error) -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/racheting_validator.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/racheting_validator.go deleted file mode 100644 index a79e15a40..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/racheting_validator.go +++ /dev/null @@ -1,53 +0,0 @@ -package manifestcomparators - -import ( - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func RatchetCompare(validator SingleCRDValidator, existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition) (ComparisonResults, error) { - var oldResults ComparisonResults - if existingCRD != nil { - var err error - oldResults, err = validator.Validate(existingCRD) - if err != nil { - return ComparisonResults{}, err - } - } - - newResults, err := validator.Validate(newCRD) - if err != nil { - return ComparisonResults{}, err - } - - ret := ComparisonResults{ - Name: newResults.Name, - WhyItMatters: newResults.WhyItMatters, - Errors: stringDiff(newResults.Errors, oldResults.Errors), - Warnings: stringDiff(newResults.Warnings, oldResults.Warnings), - Infos: stringDiff(newResults.Infos, oldResults.Infos), - } - - return ret, nil -} - -func stringDiff(s1 []string, s2 []string) []string { - ret := []string{} - for _, curr := range s1 { - if stringListContains(s2, curr) { - continue - } - - ret = append(ret, curr) - } - - return ret -} - -func stringListContains(haystack []string, needle string) bool { - for _, straw := range haystack { - if straw == needle { - return true - } - } - return false -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/registry.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/registry.go deleted file mode 100644 index e060bbabd..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/registry.go +++ /dev/null @@ -1,80 +0,0 @@ -package manifestcomparators - -import ( - "fmt" - - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/util/sets" -) - -type crdComparatorRegistry struct { - comparators map[string]CRDComparator -} - -func NewRegistry() CRDComparatorRegistry { - return &crdComparatorRegistry{ - comparators: map[string]CRDComparator{}, - } -} - -func (r *crdComparatorRegistry) AddComparator(comparator CRDComparator) error { - if _, ok := r.comparators[comparator.Name()]; ok { - return fmt.Errorf("comparator/%v is already registered", comparator.Name()) - } - - r.comparators[comparator.Name()] = comparator - return nil -} - -func (r *crdComparatorRegistry) GetComparator(name string) (CRDComparator, error) { - ret, ok := r.comparators[name] - if !ok { - return nil, fmt.Errorf("comparator/%v is not registered", name) - - } - return ret, nil -} - -func (r *crdComparatorRegistry) KnownComparators() []string { - keys := sets.StringKeySet(r.comparators) - return keys.List() -} - -func (r *crdComparatorRegistry) AllComparators() []CRDComparator { - ret := []CRDComparator{} - - keys := sets.StringKeySet(r.comparators) - for _, name := range keys.List() { - ret = append(ret, r.comparators[name]) - } - - return ret -} - -func (r *crdComparatorRegistry) Compare(existingCRD, newCRD *apiextensionsv1.CustomResourceDefinition, names ...string) ([]ComparisonResults, []error) { - comparators := []CRDComparator{} - if len(names) == 0 { - comparators = r.AllComparators() - } else { - for _, name := range names { - comparator, err := r.GetComparator(name) - if err != nil { - return nil, []error{err} - } - comparators = append(comparators, comparator) - } - } - - ret := []ComparisonResults{} - errs := []error{} - for _, comparator := range comparators { - currResults, err := comparator.Compare(existingCRD, newCRD) - if err != nil { - errs = append(errs, err) - continue - } - ret = append(ret, currResults) - } - - return ret, errs -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/simple_tester.go b/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/simple_tester.go deleted file mode 100644 index 24e3bafd8..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/manifestcomparators/simple_tester.go +++ /dev/null @@ -1,292 +0,0 @@ -package manifestcomparators - -import ( - "bufio" - "bytes" - "os" - "path/filepath" - "reflect" - "testing" - - "github.com/openshift/crd-schema-checker/pkg/resourceread" - "gopkg.in/yaml.v2" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" -) - -func AllTestsInDir(directory string) ([]ComparatorTest, error) { - ret := []ComparatorTest{} - err := filepath.WalkDir(directory, func(path string, info os.DirEntry, err error) error { - if err != nil { - return err - } - if !info.IsDir() { - return nil - } - - if containsDirectory, err := containsDir(path); err != nil { - return err - } else if containsDirectory { - return nil - } - - // so now we have only leave nodes - relativePath, err := filepath.Rel(directory, path) - if err != nil { - return err - } - - currTest, err := TestInDir(relativePath, path) - if err != nil { - return err - } - ret = append(ret, currTest) - - return nil - }) - if err != nil { - return nil, err - } - - return ret, nil -} - -func AllTestsInDirForComparator(comparator CRDComparator, directory string) ([]*simpleComparatorTest, error) { - registry := NewRegistry() - registry.AddComparator(comparator) - return AllTestsInDirForRegistry(registry, directory) -} - -func RunAllTestsInDirForComparator(t *testing.T, comparator CRDComparator, directory string) { - tests, err := AllTestsInDirForComparator(comparator, directory) - if err != nil { - t.Fatal(err) - } - - for _, test := range tests { - t.Run(test.ComparatorTest.Name, test.Test) - } -} - -func RunAllTestsInDirForRegistry(t *testing.T, registry CRDComparatorRegistry, directory string) { - tests, err := AllTestsInDirForRegistry(registry, directory) - if err != nil { - t.Fatal(err) - } - - for _, test := range tests { - t.Run(test.ComparatorTest.Name, test.Test) - } -} - -func AllTestsInDirForRegistry(registry CRDComparatorRegistry, directory string) ([]*simpleComparatorTest, error) { - tests, err := AllTestsInDir(directory) - if err != nil { - return nil, err - } - ret := []*simpleComparatorTest{} - - for i := range tests { - ret = append(ret, &simpleComparatorTest{ - ComparatorTest: tests[i], - registry: registry, - }) - } - - return ret, nil -} - -func TestInDir(testName, directory string) (ComparatorTest, error) { - ret := ComparatorTest{ - Name: testName, - } - - optionalExistingCRDFile := filepath.Join(directory, "existing.yaml") - existingBytes, err := os.ReadFile(optionalExistingCRDFile) - if err != nil && !os.IsNotExist(err) { - return ComparatorTest{}, err - } - if len(existingBytes) > 0 { - crd, err := resourceread.ReadCustomResourceDefinitionV1(existingBytes) - if err != nil { - return ComparatorTest{}, err - } - ret.ExistingCRD = crd - } - - requiredNewCRDFile := filepath.Join(directory, "new.yaml") - newBytes, err := os.ReadFile(requiredNewCRDFile) - if err != nil { - return ComparatorTest{}, err - } - newCRD, err := resourceread.ReadCustomResourceDefinitionV1(newBytes) - if err != nil { - return ComparatorTest{}, err - } - ret.NewCRD = newCRD - - optionalExpectedFile := filepath.Join(directory, "expected.yaml") - expectedBytes, err := os.ReadFile(optionalExpectedFile) - if err != nil && !os.IsNotExist(err) { - return ComparatorTest{}, err - } - if len(expectedBytes) > 0 { - expected := &ComparisonResultsList{} - if err := yaml.Unmarshal(expectedBytes, expected); err != nil { - return ComparatorTest{}, err - } - ret.ExpectedResults = expected.Items - } - - optionalExpectedErrorsFile := filepath.Join(directory, "errors.txt") - expectedErrorsBytes, err := os.ReadFile(optionalExpectedErrorsFile) - if err != nil && !os.IsNotExist(err) { - return ComparatorTest{}, err - } - if len(expectedErrorsBytes) > 0 { - expectedErrors := []string{} - scanner := bufio.NewScanner(bytes.NewBuffer(expectedErrorsBytes)) - for scanner.Scan() { - expectedErrors = append(expectedErrors, scanner.Text()) - } - if err := scanner.Err(); err != nil { - return ComparatorTest{}, err - } - ret.ExpectedErrors = expectedErrors - } - - return ret, nil -} - -func containsDir(path string) (bool, error) { - entries, err := os.ReadDir(path) - if err != nil { - return false, err - } - for _, entry := range entries { - if entry.IsDir() { - return true, nil - } - } - return false, nil -} - -type ComparisonResultsList struct { - Items []ComparisonResults `yaml:"items"` -} - -// ComparatorTest represents the directory style test we have. -type ComparatorTest struct { - Name string - ExistingCRD *apiextensionsv1.CustomResourceDefinition - NewCRD *apiextensionsv1.CustomResourceDefinition - - ExpectedResults []ComparisonResults - ExpectedErrors []string -} - -type simpleComparatorTest struct { - ComparatorTest ComparatorTest - registry CRDComparatorRegistry -} - -func (tc *simpleComparatorTest) Test(t *testing.T) { - actualResults, actualErrors := tc.registry.Compare(tc.ComparatorTest.ExistingCRD, tc.ComparatorTest.NewCRD) - tc.ComparatorTest.Test(t, actualResults, actualErrors) -} - -func (tc *ComparatorTest) Test(t *testing.T, actualResults []ComparisonResults, actualErrors []error) { - switch { - case len(tc.ExpectedErrors) == 0 && len(actualErrors) == 0: - case len(tc.ExpectedErrors) == 0 && len(actualErrors) != 0: - t.Fatalf("0 errors expected, got %v", actualErrors) - case len(tc.ExpectedErrors) != 0 && len(actualErrors) == 0: - t.Fatalf("expected some errors: %v, got none", tc.ExpectedErrors) - case len(tc.ExpectedErrors) != 0 && len(actualErrors) != 0: - if !reflect.DeepEqual(tc.ExpectedErrors, actualErrors) { - t.Fatalf("expected some errors: %v, got different errors: %v", tc.ExpectedErrors, actualErrors) - } - } - - // check to be sure that every expected message appeared - for _, expected := range tc.ExpectedResults { - expectedBytes, err := yaml.Marshal(expected) - if err != nil { - t.Error(err) - } - - actualPtr := findResultsForComparator(expected.Name, actualResults) - if actualPtr == nil { - // this is only an error when we expect a message - if len(expected.Errors) == 0 && len(expected.Warnings) == 0 && len(expected.Infos) == 0 { - continue - } - t.Errorf("missing expectedResults[%v]: expected\n%v\n", expected.Name, string(expectedBytes)) - continue - } - - actual := *actualPtr - actualBytes, err := yaml.Marshal(actual) - if err != nil { - t.Error(err) - } - noErrorsAsExpected := len(expected.Errors) == 0 && len(actual.Errors) == 0 - if !noErrorsAsExpected && !reflect.DeepEqual(expected.Errors, actual.Errors) { - t.Errorf("mismatched errors for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - noWarningsAsExpected := len(expected.Warnings) == 0 && len(actual.Warnings) == 0 - if !noWarningsAsExpected && !reflect.DeepEqual(expected.Warnings, actual.Warnings) { - t.Errorf("mismatched warnings for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - noInfosAsExpected := len(expected.Infos) == 0 && len(actual.Infos) == 0 - if !noInfosAsExpected && !reflect.DeepEqual(expected.Infos, actual.Infos) { - t.Errorf("mismatched infos for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - } - - // check to be sure that we didn't get an extra message - for _, actual := range actualResults { - actualBytes, err := yaml.Marshal(actual) - if err != nil { - t.Error(err) - } - - expectedPtr := findResultsForComparator(actual.Name, tc.ExpectedResults) - if expectedPtr == nil { - // this is only an error when we expect a message - if len(actual.Errors) == 0 && len(actual.Warnings) == 0 && len(actual.Infos) == 0 { - continue - } - t.Errorf("missing expectedResults for actual[%v]: got\n%v\n", actual.Name, string(actualBytes)) - continue - } - - expected := *expectedPtr - expectedBytes, err := yaml.Marshal(expected) - if err != nil { - t.Error(err) - } - noErrorsAsExpected := len(expected.Errors) == 0 && len(actual.Errors) == 0 - if !noErrorsAsExpected && !reflect.DeepEqual(expected.Errors, actual.Errors) { - t.Errorf("mismatched errors for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - noWarningsAsExpected := len(expected.Warnings) == 0 && len(actual.Warnings) == 0 - if !noWarningsAsExpected && !reflect.DeepEqual(expected.Warnings, actual.Warnings) { - t.Errorf("mismatched warnings for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - noInfosAsExpected := len(expected.Infos) == 0 && len(actual.Infos) == 0 - if !noInfosAsExpected && !reflect.DeepEqual(expected.Infos, actual.Infos) { - t.Errorf("mismatched infos for expectedResults[%v]: expected\n%v\n, got\n%v\n", expected.Name, string(expectedBytes), string(actualBytes)) - } - } - -} - -func findResultsForComparator(name string, results []ComparisonResults) *ComparisonResults { - for i := range results { - if results[i].Name == name { - return &results[i] - } - } - - return nil -} diff --git a/vendor/github.com/openshift/crd-schema-checker/pkg/resourceread/apiextensions.go b/vendor/github.com/openshift/crd-schema-checker/pkg/resourceread/apiextensions.go deleted file mode 100644 index 824da55aa..000000000 --- a/vendor/github.com/openshift/crd-schema-checker/pkg/resourceread/apiextensions.go +++ /dev/null @@ -1,37 +0,0 @@ -package resourceread - -import ( - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/serializer" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" -) - -var ( - apiExtensionsScheme = runtime.NewScheme() - apiExtensionsCodecs = serializer.NewCodecFactory(apiExtensionsScheme) -) - -func init() { - utilruntime.Must(apiextensionsv1.AddToScheme(apiExtensionsScheme)) -} - -func ReadCustomResourceDefinitionV1(objBytes []byte) (*apiextensionsv1.CustomResourceDefinition, error) { - requiredObj, err := runtime.Decode(apiExtensionsCodecs.UniversalDecoder(apiextensionsv1.SchemeGroupVersion), objBytes) - if err != nil { - return nil, err - } - return requiredObj.(*apiextensionsv1.CustomResourceDefinition), nil -} - -func ReadCustomResourceDefinitionV1OrDie(objBytes []byte) *apiextensionsv1.CustomResourceDefinition { - requiredObj, err := runtime.Decode(apiExtensionsCodecs.UniversalDecoder(apiextensionsv1.SchemeGroupVersion), objBytes) - if err != nil { - panic(err) - } - return requiredObj.(*apiextensionsv1.CustomResourceDefinition) -} - -func WriteCustomResourceDefinitionV1OrDie(obj *apiextensionsv1.CustomResourceDefinition) string { - return runtime.EncodeOrDie(apiExtensionsCodecs.LegacyCodec(apiextensionsv1.SchemeGroupVersion), obj) -} diff --git a/vendor/modules.txt b/vendor/modules.txt index b6d8a2ec3..f10e5ae0f 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -718,10 +718,6 @@ github.com/opencontainers/image-spec/specs-go/v1 # github.com/opencontainers/runtime-spec v1.2.1 ## explicit github.com/opencontainers/runtime-spec/specs-go -# github.com/openshift/crd-schema-checker v0.0.0-20240404194209-35a9033b1d11 -## explicit; go 1.20 -github.com/openshift/crd-schema-checker/pkg/manifestcomparators -github.com/openshift/crd-schema-checker/pkg/resourceread # github.com/operator-framework/api v0.30.0 ## explicit; go 1.23.0 github.com/operator-framework/api/pkg/constraints