diff --git a/.bingo/Variables.mk b/.bingo/Variables.mk index 5ee29939cc..4944836589 100644 --- a/.bingo/Variables.mk +++ b/.bingo/Variables.mk @@ -35,11 +35,11 @@ $(CONTROLLER_GEN): $(BINGO_DIR)/controller-gen.mod @echo "(re)installing $(GOBIN)/controller-gen-v0.20.1" @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=controller-gen.mod -o=$(GOBIN)/controller-gen-v0.20.1 "sigs.k8s.io/controller-tools/cmd/controller-gen" -CRD_DIFF := $(GOBIN)/crd-diff-v0.5.0 +CRD_DIFF := $(GOBIN)/crd-diff-v0.5.1-0.20260309184313-54162f2e3097 $(CRD_DIFF): $(BINGO_DIR)/crd-diff.mod @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. - @echo "(re)installing $(GOBIN)/crd-diff-v0.5.0" - @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=crd-diff.mod -o=$(GOBIN)/crd-diff-v0.5.0 "sigs.k8s.io/crdify" + @echo "(re)installing $(GOBIN)/crd-diff-v0.5.1-0.20260309184313-54162f2e3097" + @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=crd-diff.mod -o=$(GOBIN)/crd-diff-v0.5.1-0.20260309184313-54162f2e3097 "sigs.k8s.io/crdify" CRD_REF_DOCS := $(GOBIN)/crd-ref-docs-v0.3.0 $(CRD_REF_DOCS): $(BINGO_DIR)/crd-ref-docs.mod diff --git a/.bingo/crd-diff.mod b/.bingo/crd-diff.mod index 65f8c6a0f9..ada6dbc70b 100644 --- a/.bingo/crd-diff.mod +++ b/.bingo/crd-diff.mod @@ -2,4 +2,4 @@ module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT go 1.24.6 -require sigs.k8s.io/crdify v0.5.0 +require sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097 diff --git a/.bingo/crd-diff.sum b/.bingo/crd-diff.sum index acf7a5ab91..a2a9858c62 100644 --- a/.bingo/crd-diff.sum +++ b/.bingo/crd-diff.sum @@ -251,6 +251,8 @@ sigs.k8s.io/controller-runtime v0.16.2 h1:mwXAVuEk3EQf478PQwQ48zGOXvW27UJc8NHktQ sigs.k8s.io/controller-runtime v0.16.2/go.mod h1:vpMu3LpI5sYWtujJOa2uPK61nB5rbwlN7BAB8aSLvGU= sigs.k8s.io/crdify v0.5.0 h1:mrMH9CgXQPTZUpTU6Klqfnlys8bggv/7uvLT2lXSP7A= sigs.k8s.io/crdify v0.5.0/go.mod h1:ZIFxaYNgKYmFtZCLPysncXQ8oqwnNlHQbRUfxJHZwzU= +sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097 h1:gwDRFCc64lhEpxY944IJFW+CrmMFXWH+JjpE0JHp42Y= +sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097/go.mod h1:ZIFxaYNgKYmFtZCLPysncXQ8oqwnNlHQbRUfxJHZwzU= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= diff --git a/.bingo/variables.env b/.bingo/variables.env index ec6e75c3fe..783b778af8 100644 --- a/.bingo/variables.env +++ b/.bingo/variables.env @@ -14,7 +14,7 @@ CONFTEST="${GOBIN}/conftest-v0.62.0" CONTROLLER_GEN="${GOBIN}/controller-gen-v0.20.1" -CRD_DIFF="${GOBIN}/crd-diff-v0.5.0" +CRD_DIFF="${GOBIN}/crd-diff-v0.5.1-0.20260309184313-54162f2e3097" CRD_REF_DOCS="${GOBIN}/crd-ref-docs-v0.3.0" diff --git a/docs/concepts/large-bundle-support.md b/docs/draft/concepts/large-bundle-support.md similarity index 96% rename from docs/concepts/large-bundle-support.md rename to docs/draft/concepts/large-bundle-support.md index cf1c247cdc..325aaacc1c 100644 --- a/docs/concepts/large-bundle-support.md +++ b/docs/draft/concepts/large-bundle-support.md @@ -139,12 +139,19 @@ follow for consistency and safe lifecycle management. Recommended conventions: -1. **Immutability**: Secrets should set `immutable: true`. Because COS phases +1. **Secret type**: Secrets should use the dedicated type + `olm.operatorframework.io/object-data` to distinguish them from user-created + Secrets and enable easy identification. The system always sets this type on + Secrets it creates. The reconciler does not enforce the type when resolving + refs — Secrets with any type are accepted — but producers should set it for + consistency. + +2. **Immutability**: Secrets should set `immutable: true`. Because COS phases are immutable, the content backing a ref should not change after creation. Mutable referenced Secrets are not rejected, but modifying them after the COS is created leads to undefined behavior. -2. **Owner references**: Referenced Secrets should carry an ownerReference to +3. **Owner references**: Referenced Secrets should carry an ownerReference to the COS so that Kubernetes garbage collection removes them when the COS is deleted: ```yaml @@ -159,7 +166,7 @@ Recommended conventions: Secret when the COS is deleted. The reconciler does not delete referenced Secrets itself. -3. **Revision label**: A label identifying the owning revision aids discovery, +4. **Revision label**: A label identifying the owning revision aids discovery, debugging, and bulk cleanup: ``` olm.operatorframework.io/revision-name: @@ -237,6 +244,7 @@ metadata: uid: controller: true immutable: true +type: olm.operatorframework.io/object-data data: service-account: cluster-role: @@ -255,6 +263,7 @@ metadata: uid: controller: true immutable: true +type: olm.operatorframework.io/object-data data: my-crd: --- @@ -272,6 +281,7 @@ metadata: uid: controller: true immutable: true +type: olm.operatorframework.io/object-data data: deployment: ``` @@ -653,7 +663,7 @@ rollout semantics are unchanged. | **Crash safety** | 3-step: Secrets → COS → patch ownerRefs; orphan cleanup via revision label | 2-step: COS → Secrets with ownerRefs; simpler but reconciler may see missing Secrets temporarily | | **Flexibility** | Mixed inline/ref per object within the same phase is possible | All-or-nothing — either all phases inline or all externalized | | **Storage efficiency** | Per-object compression misses cross-object redundancy; potentially more Secrets created in edge cases | Better compression from cross-phase redundancy; fewer Secrets | -| **Resource type** | Secret only | Secret only (with dedicated type) | +| **Resource type** | Secret with dedicated type `olm.operatorframework.io/object-data` | Secret with dedicated type `olm.operatorframework.io/revision-phase-data` | | **Phases structure** | Unchanged — phases array preserved as-is; only individual objects gain a new resolution path | Replaced at the top level — phases field swapped for phasesRef | | **Content addressability** | Content hash as Secret data key — key changes when content changes | Content hash embedded in Secret name — detects changes without fetching contents | diff --git a/go.mod b/go.mod index ca715c9fe9..757b21ee35 100644 --- a/go.mod +++ b/go.mod @@ -45,10 +45,10 @@ require ( k8s.io/klog/v2 v2.140.0 k8s.io/kubernetes v1.35.0 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 - pkg.package-operator.run/boxcutter v0.12.0 + pkg.package-operator.run/boxcutter v0.13.0 sigs.k8s.io/controller-runtime v0.23.3 sigs.k8s.io/controller-tools v0.20.1 - sigs.k8s.io/crdify v0.5.0 + sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097 sigs.k8s.io/structured-merge-diff/v6 v6.3.2 sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index b60ab84ffa..b1c047d19d 100644 --- a/go.sum +++ b/go.sum @@ -796,16 +796,16 @@ k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbe k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= -pkg.package-operator.run/boxcutter v0.12.0 h1:9XAi8MjwghfNHyuWzhqrEjY+XIGUz2rg+A72KITb+wA= -pkg.package-operator.run/boxcutter v0.12.0/go.mod h1:Bo1tgiXCYJtehp5p+2aTrKt7VnJhrGKSSBvUQofnDRg= +pkg.package-operator.run/boxcutter v0.13.0 h1:LNUS36NFkI+6J1CcVMrmTOtZt8UoalwXcIDlTPG66C4= +pkg.package-operator.run/boxcutter v0.13.0/go.mod h1:Bo1tgiXCYJtehp5p+2aTrKt7VnJhrGKSSBvUQofnDRg= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= sigs.k8s.io/controller-tools v0.20.1 h1:gkfMt9YodI0K85oT8rVi80NTXO/kDmabKR5Ajn5GYxs= sigs.k8s.io/controller-tools v0.20.1/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= -sigs.k8s.io/crdify v0.5.0 h1:mrMH9CgXQPTZUpTU6Klqfnlys8bggv/7uvLT2lXSP7A= -sigs.k8s.io/crdify v0.5.0/go.mod h1:ZIFxaYNgKYmFtZCLPysncXQ8oqwnNlHQbRUfxJHZwzU= +sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097 h1:gwDRFCc64lhEpxY944IJFW+CrmMFXWH+JjpE0JHp42Y= +sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097/go.mod h1:ZIFxaYNgKYmFtZCLPysncXQ8oqwnNlHQbRUfxJHZwzU= sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/operator-controller/applier/phase.go b/internal/operator-controller/applier/phase.go index 02ee8decf8..a0a85e9152 100644 --- a/internal/operator-controller/applier/phase.go +++ b/internal/operator-controller/applier/phase.go @@ -2,19 +2,11 @@ package applier import ( "cmp" - "encoding/json" - "fmt" "slices" - "strings" - "k8s.io/apimachinery/pkg/api/equality" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - "pkg.package-operator.run/boxcutter/probing" - "sigs.k8s.io/controller-runtime/pkg/client" ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/util" ) // The following, with modifications, is taken from: @@ -206,58 +198,3 @@ func PhaseSort(unsortedObjs []ocv1ac.ClusterObjectSetObjectApplyConfiguration) [ return phasesSorted } - -// FieldValueProbe checks if the value found at FieldPath matches the provided Value -type FieldValueProbe struct { - FieldPath, Value string -} - -var _ probing.Prober = (*FieldValueProbe)(nil) - -// Probe executes the probe. -func (fe *FieldValueProbe) Probe(obj client.Object) probing.Result { - uMap, err := util.ToUnstructured(obj) - if err != nil { - return probing.UnknownResult(fmt.Sprintf("failed to convert to unstructured: %v", err)) - } - return fe.probe(uMap) -} - -func (fv *FieldValueProbe) probe(obj *unstructured.Unstructured) probing.Result { - fieldPath := strings.Split(strings.Trim(fv.FieldPath, "."), ".") - - fieldVal, ok, err := unstructured.NestedFieldCopy(obj.Object, fieldPath...) - if err != nil { - return probing.Result{ - Status: probing.StatusFalse, - Messages: []string{fmt.Sprintf(`error locating key %q; %v`, fv.FieldPath, err)}, - } - } - if !ok { - return probing.Result{ - Status: probing.StatusFalse, - Messages: []string{fmt.Sprintf(`missing key: %q`, fv.FieldPath)}, - } - } - - if !equality.Semantic.DeepEqual(fieldVal, fv.Value) { - foundJSON, err := json.Marshal(fieldVal) - if err != nil { - foundJSON = []byte("") - } - expectedJSON, err := json.Marshal(fv.Value) - if err != nil { - expectedJSON = []byte("") - } - - return probing.Result{ - Status: probing.StatusFalse, - Messages: []string{fmt.Sprintf(`value at key %q != %q; expected: %s got: %s`, fv.FieldPath, fv.Value, expectedJSON, foundJSON)}, - } - } - - return probing.Result{ - Status: probing.StatusTrue, - Messages: []string{fmt.Sprintf(`value at key %q == %q`, fv.FieldPath, fv.Value)}, - } -} diff --git a/internal/operator-controller/applier/phase_test.go b/internal/operator-controller/applier/phase_test.go index 443d0007ec..2a4ade8655 100644 --- a/internal/operator-controller/applier/phase_test.go +++ b/internal/operator-controller/applier/phase_test.go @@ -3,14 +3,9 @@ package applier_test import ( "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/utils/ptr" - "pkg.package-operator.run/boxcutter/probing" - "sigs.k8s.io/controller-runtime/pkg/client" ocv1ac "github.com/operator-framework/operator-controller/applyconfigurations/api/v1" "github.com/operator-framework/operator-controller/internal/operator-controller/applier" @@ -1036,82 +1031,3 @@ func Test_PhaseSort(t *testing.T) { }) } } - -func Test_FieldValueProbe(t *testing.T) { - for _, tc := range []struct { - name string - obj client.Object - probe applier.FieldValueProbe - expectedResult probing.Result - }{ - { - name: "True result with found key and equal value", - obj: &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, - }, - probe: applier.FieldValueProbe{ - FieldPath: "metadata.name", - Value: "my-service", - }, - expectedResult: probing.Result{ - Status: probing.StatusTrue, - Messages: []string{ - `value at key "metadata.name" == "my-service"`, - }, - }, - }, - { - name: "False result with unfound key", - obj: &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, - }, - probe: applier.FieldValueProbe{ - FieldPath: "spec.foo", - Value: "my-service", - }, - expectedResult: probing.Result{ - Status: probing.StatusFalse, - Messages: []string{ - `missing key: "spec.foo"`, - }, - }, - }, - { - name: "False result with found key and unequal value", - obj: &corev1.Service{ - TypeMeta: metav1.TypeMeta{Kind: "Service", APIVersion: "v1"}, - ObjectMeta: metav1.ObjectMeta{Name: "my-service", Namespace: "my-namespace"}, - }, - probe: applier.FieldValueProbe{ - FieldPath: "metadata.namespace", - Value: "bar", - }, - expectedResult: probing.Result{ - Status: probing.StatusFalse, - Messages: []string{ - `value at key "metadata.namespace" != "bar"; expected: "bar" got: "my-namespace"`, - }, - }, - }, - { - name: "Unknown result unstructured conversion failure", - obj: nil, - probe: applier.FieldValueProbe{ - FieldPath: "metadata.name", - Value: "my-service", - }, - expectedResult: probing.Result{ - Status: probing.StatusUnknown, - Messages: []string{ - "failed to convert to unstructured: object is nil", - }, - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.expectedResult, tc.probe.Probe(tc.obj)) - }) - } -} diff --git a/internal/operator-controller/applier/secretpacker.go b/internal/operator-controller/applier/secretpacker.go index ac88e5267c..45ae9fdb04 100644 --- a/internal/operator-controller/applier/secretpacker.go +++ b/internal/operator-controller/applier/secretpacker.go @@ -143,6 +143,7 @@ func (p *SecretPacker) newSecret(data map[string][]byte) corev1.Secret { }, }, Immutable: ptr.To(true), + Type: labels.SecretTypeObjectData, Data: data, } } diff --git a/internal/operator-controller/applier/secretpacker_test.go b/internal/operator-controller/applier/secretpacker_test.go index fcd19d1823..9603cbe873 100644 --- a/internal/operator-controller/applier/secretpacker_test.go +++ b/internal/operator-controller/applier/secretpacker_test.go @@ -46,6 +46,7 @@ func TestSecretPacker_Pack(t *testing.T) { assert.True(t, strings.HasPrefix(result.Secrets[0].Name, "my-ext-3-"), "Secret name should be content-addressable with revision prefix") assert.Equal(t, "olmv1-system", result.Secrets[0].Namespace) assert.True(t, *result.Secrets[0].Immutable) + assert.Equal(t, labels.SecretTypeObjectData, result.Secrets[0].Type) assert.Equal(t, "my-ext-3", result.Secrets[0].Labels[labels.RevisionNameKey]) assert.Equal(t, "my-ext", result.Secrets[0].Labels[labels.OwnerNameKey]) diff --git a/internal/operator-controller/controllers/clusterobjectset_controller.go b/internal/operator-controller/controllers/clusterobjectset_controller.go index bca371613c..055f962ce6 100644 --- a/internal/operator-controller/controllers/clusterobjectset_controller.go +++ b/internal/operator-controller/controllers/clusterobjectset_controller.go @@ -38,7 +38,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" ocv1 "github.com/operator-framework/operator-controller/api/v1" - "github.com/operator-framework/operator-controller/internal/operator-controller/applier" "github.com/operator-framework/operator-controller/internal/operator-controller/labels" ) @@ -643,7 +642,7 @@ func buildProgressionProbes(progressionProbes []ocv1.ProgressionProbe) (probing. fieldsEqualProbe := probing.FieldsEqualProbe(probe.FieldsEqual) assertions = append(assertions, &fieldsEqualProbe) case ocv1.ProbeTypeFieldValue: - fieldValueProbe := applier.FieldValueProbe(probe.FieldValue) + fieldValueProbe := probing.FieldValueProbe(probe.FieldValue) assertions = append(assertions, &fieldValueProbe) default: return nil, fmt.Errorf("unknown progressionProbe assertion probe type: %s", probe.Type) diff --git a/internal/operator-controller/labels/labels.go b/internal/operator-controller/labels/labels.go index 27d34e9a80..805c34c3b0 100644 --- a/internal/operator-controller/labels/labels.go +++ b/internal/operator-controller/labels/labels.go @@ -1,6 +1,13 @@ package labels +import corev1 "k8s.io/api/core/v1" + const ( + // SecretTypeObjectData is the custom Secret type used for Secrets that store + // externalized object content referenced by ClusterObjectSet ref entries. + // It distinguishes OLM-managed ref Secrets from user-created Secrets. + SecretTypeObjectData corev1.SecretType = "olm.operatorframework.io/object-data" //nolint:gosec // G101 false positive: this is a Kubernetes Secret type identifier, not a credential + // OwnerKindKey is the label key used to record the kind of the owner // resource responsible for creating or managing a ClusterObjectSet. OwnerKindKey = "olm.operatorframework.io/owner-kind" diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go index e7830ce620..3bcd0280ae 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety.go @@ -160,15 +160,15 @@ func sameVersionErrors(results *runner.Results) []error { } errs := []error{} - for version, propertyResults := range results.SameVersionValidation { - for property, comparisonResults := range propertyResults { - for _, result := range comparisonResults { + for _, versionResult := range results.SameVersionValidation { + for _, propertyResult := range versionResult.PropertyComparisons { + for _, result := range propertyResult.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)) + errs = append(errs, fmt.Errorf("%s: %s: %s: %s", versionResult.Version, propertyResult.Property, result.Name, msg)) } } } @@ -183,15 +183,15 @@ func servedVersionErrors(results *runner.Results) []error { } errs := []error{} - for version, propertyResults := range results.ServedVersionValidation { - for property, comparisonResults := range propertyResults { - for _, result := range comparisonResults { + for _, versionResult := range results.ServedVersionValidation { + for _, propertyResult := range versionResult.PropertyComparisons { + for _, result := range propertyResult.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)) + errs = append(errs, fmt.Errorf("%s: %s: %s: %s", versionResult.Version, propertyResult.Property, result.Name, msg)) } } } diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go index 9fb275880d..02f1bd7bd0 100644 --- a/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/crdupgradesafety_test.go @@ -191,6 +191,14 @@ func TestInstall(t *testing.T) { Manifest: getManifestString(t, "crd-description-changed.json"), }, }, + { + name: "optional field addition should not fail", + oldCrdPath: "crd-optional-field-old.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-optional-field-new.json"), + }, + }, } for _, tc := range tests { @@ -370,6 +378,35 @@ func TestUpgrade(t *testing.T) { ) }, }, + { + name: "optional field addition should not fail", + oldCrdPath: "crd-optional-field-old.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-optional-field-new.json"), + }, + }, + { + name: "complex breaking changes should fail", + oldCrdPath: "crd-complex-breaking-changes-old.json", + release: &release.Release{ + Name: "test-release", + Manifest: getManifestString(t, "crd-complex-breaking-changes-new.json"), + }, + // This test verifies detection of multiple breaking changes in a single CRD upgrade: + // 1. Type changed from "object" to "" - Properly detected by type validator + // 2. Nullable changed from false to true - Properly detected by nullable validator + // 3. OneOf constraint added - Reported as "unhandled" (needs crdify support) + // See: https://github.com/kubernetes-sigs/crdify/issues/25 + // The upgrade is correctly blocked, but OneOf changes need better categorization. + requireErr: wantErrorMsgs([]string{ + `validating upgrade for CRD "services.networking.example.com"`, + `type: type changed`, + `nullable: nullable added`, + `unhandled: unhandled changes found`, + `OneOf`, + }), + }, } for _, tc := range tests { diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-new.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-new.json new file mode 100644 index 0000000000..024027fce8 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-new.json @@ -0,0 +1,106 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "services.networking.example.com" + }, + "spec": { + "group": "networking.example.com", + "versions": [ + { + "name": "v1beta1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "ingress": { + "type": "object", + "properties": { + "gateway": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hosts": { + "description": "One or more hosts exposed by this gateway", + "type": "array", + "items": { + "type": "string" + } + }, + "port": { + "type": "object", + "properties": { + "name": { + "description": "Label assigned to the port", + "type": "string" + }, + "number": { + "description": "Port number", + "type": "integer" + } + } + }, + "tls": { + "nullable": true, + "oneOf": [ + { + "required": ["mode", "credentialName"] + }, + { + "required": ["httpsRedirect"] + } + ], + "properties": { + "credentialName": { + "description": "TLS certificate name", + "type": "string" + }, + "httpsRedirect": { + "description": "If set to true, the load balancer will send a 301 redirect to HTTPS", + "type": "boolean" + }, + "mode": { + "description": "TLS mode", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "services", + "singular": "service", + "kind": "Service", + "shortNames": [ + "svc" + ] + } + }, + "status": { + "storedVersions": [ + "v1beta1" + ] + } +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-old.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-old.json new file mode 100644 index 0000000000..ba7239f945 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-complex-breaking-changes-old.json @@ -0,0 +1,95 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "services.networking.example.com" + }, + "spec": { + "group": "networking.example.com", + "versions": [ + { + "name": "v1beta1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "ingress": { + "type": "object", + "properties": { + "gateway": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hosts": { + "description": "One or more hosts exposed by this gateway", + "type": "array", + "items": { + "type": "string" + } + }, + "port": { + "type": "object", + "properties": { + "name": { + "description": "Label assigned to the port", + "type": "string" + }, + "number": { + "description": "Port number", + "type": "integer" + } + } + }, + "tls": { + "type": "object", + "nullable": false, + "properties": { + "credentialName": { + "description": "TLS certificate name", + "type": "string" + }, + "mode": { + "description": "TLS mode", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "services", + "singular": "service", + "kind": "Service", + "shortNames": [ + "svc" + ] + } + }, + "status": { + "storedVersions": [ + "v1beta1" + ] + } +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-new.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-new.json new file mode 100644 index 0000000000..0e5bc42f3d --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-new.json @@ -0,0 +1,160 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "myapps.apps.example.com" + }, + "spec": { + "group": "apps.example.com", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "extraFiles": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "mountPath": { + "description": "Path to mount the Object. If not specified default-path/Name will be used", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + }, + "configMaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "mountPath": { + "description": "Path to mount the Object. If not specified default-path/Name will be used", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + { + "name": "v1alpha3", + "served": true, + "storage": false, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "extraFiles": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "mountPath": { + "description": "Path to mount the Object. If not specified default-path/Name will be used", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + }, + "configMaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "mountPath": { + "description": "Path to mount the Object. If not specified default-path/Name will be used", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "myapps", + "singular": "myapp", + "kind": "MyApp", + "shortNames": [ + "bs" + ] + } + }, + "status": { + "storedVersions": [ + "v1alpha1" + ] + } +} diff --git a/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-old.json b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-old.json new file mode 100644 index 0000000000..fc2d4582e3 --- /dev/null +++ b/internal/operator-controller/rukpak/preflights/crdupgradesafety/testdata/manifests/crd-optional-field-old.json @@ -0,0 +1,144 @@ +{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": { + "name": "myapps.apps.example.com" + }, + "spec": { + "group": "apps.example.com", + "versions": [ + { + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "extraFiles": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + }, + "configMaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + { + "name": "v1alpha3", + "served": true, + "storage": false, + "schema": { + "openAPIV3Schema": { + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "extraFiles": { + "type": "object", + "properties": { + "secrets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + }, + "configMaps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "description": "Key in the object", + "type": "string" + }, + "name": { + "description": "Name of the object\nWe support only ConfigMaps and Secrets.", + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + } + } + ], + "scope": "Namespaced", + "names": { + "plural": "myapps", + "singular": "myapp", + "kind": "MyApp", + "shortNames": [ + "bs" + ] + } + }, + "status": { + "storedVersions": [ + "v1alpha1" + ] + } +} diff --git a/test/e2e/features/install.feature b/test/e2e/features/install.feature index 0e34383e94..74a90beb1c 100644 --- a/test/e2e/features/install.feature +++ b/test/e2e/features/install.feature @@ -6,7 +6,7 @@ Feature: Install ClusterExtension Background: Given OLM is available And ClusterCatalog "test" serves bundles - And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + And ServiceAccount "olm-sa" with needed permissions is available in test namespace Scenario: Install latest available version When ClusterExtension is applied @@ -529,6 +529,7 @@ Feature: Install ClusterExtension And ClusterObjectSet "${NAME}-1" ref Secrets are immutable And ClusterObjectSet "${NAME}-1" ref Secrets are labeled with revision and owner And ClusterObjectSet "${NAME}-1" ref Secrets have ownerReference to the revision + And ClusterObjectSet "${NAME}-1" ref Secrets have type "olm.operatorframework.io/object-data" @DeploymentConfig Scenario: deploymentConfig nodeSelector is applied to the operator deployment @@ -594,7 +595,7 @@ Feature: Install ClusterExtension @BoxcutterRuntime @PreflightPermissions Scenario: Boxcutter preflight check detects missing CREATE permissions - Given ServiceAccount "olm-sa" without create permissions is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" without create permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 diff --git a/test/e2e/features/recover.feature b/test/e2e/features/recover.feature index c222756a47..b7610c5dfc 100644 --- a/test/e2e/features/recover.feature +++ b/test/e2e/features/recover.feature @@ -5,7 +5,7 @@ Feature: Recover cluster extension from errors that might occur during its lifet And ClusterCatalog "test" serves bundles Scenario: Restore removed resource - Given ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 @@ -49,12 +49,12 @@ Feature: Recover cluster extension from errors that might occur during its lifet "olm.operatorframework.io/metadata.name": test-catalog """ And ClusterExtension reports Progressing as True with Reason Retrying - When ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + When ServiceAccount "olm-sa" with needed permissions is available in test namespace Then ClusterExtension is available And ClusterExtension reports Progressing as True with Reason Succeeded Scenario: Install ClusterExtension after conflicting resource is removed - Given ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" with needed permissions is available in test namespace And resource is applied """ apiVersion: apps/v1 @@ -118,7 +118,7 @@ Feature: Recover cluster extension from errors that might occur during its lifet @PreflightPermissions Scenario: ClusterExtension installation succeeds after service account gets the required missing permissions to manage the bundle's resources - Given ServiceAccount "olm-sa" is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 @@ -145,7 +145,7 @@ Feature: Recover cluster extension from errors that might occur during its lifet """ Namespace:"" APIGroups:[apiextensions.k8s.io] Resources:[customresourcedefinitions] ResourceNames:[olme2etests.olm.operatorframework.io] Verbs:[delete,get,patch,update] """ - When ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + When ServiceAccount "olm-sa" with needed permissions is available in test namespace Then ClusterExtension is available And ClusterExtension reports Progressing as True with Reason Succeeded And ClusterExtension reports Installed as True @@ -163,7 +163,7 @@ Feature: Recover cluster extension from errors that might occur during its lifet # - If the controller stopped reconciling, the configmap would stay deleted # - Resource restoration is an observable event that PROVES active reconciliation # - The deployment staying healthy proves the workload continues running - Given ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 @@ -203,7 +203,7 @@ Feature: Recover cluster extension from errors that might occur during its lifet # - Reconciliation completing (observedGeneration == generation) proves the spec was processed # - Progressing=Succeeded proves the controller didn't block on missing catalog # - Extension staying Available proves workload continues running - Given ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + Given ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 diff --git a/test/e2e/features/status.feature b/test/e2e/features/status.feature index 28631d6e3e..01b0bf7a96 100644 --- a/test/e2e/features/status.feature +++ b/test/e2e/features/status.feature @@ -6,7 +6,7 @@ Feature: Report status of the managed ClusterExtension workload Background: Given OLM is available And ClusterCatalog "test" serves bundles - And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + And ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 diff --git a/test/e2e/features/uninstall.feature b/test/e2e/features/uninstall.feature index e14b8494fe..89290ada68 100644 --- a/test/e2e/features/uninstall.feature +++ b/test/e2e/features/uninstall.feature @@ -6,7 +6,7 @@ Feature: Uninstall ClusterExtension Background: Given OLM is available And ClusterCatalog "test" serves bundles - And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + And ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 diff --git a/test/e2e/features/update.feature b/test/e2e/features/update.feature index 483fc328e0..ab9408365e 100644 --- a/test/e2e/features/update.feature +++ b/test/e2e/features/update.feature @@ -6,7 +6,7 @@ Feature: Update ClusterExtension Background: Given OLM is available And ClusterCatalog "test" serves bundles - And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + And ServiceAccount "olm-sa" with needed permissions is available in test namespace Scenario: Update to a successor version Given ClusterExtension is applied diff --git a/test/e2e/features/user-managed-fields.feature b/test/e2e/features/user-managed-fields.feature index 9046dc0670..6c830bbe34 100644 --- a/test/e2e/features/user-managed-fields.feature +++ b/test/e2e/features/user-managed-fields.feature @@ -7,7 +7,7 @@ Feature: Preserve user-managed fields on deployed resources Background: Given OLM is available And ClusterCatalog "test" serves bundles - And ServiceAccount "olm-sa" with needed permissions is available in ${TEST_NAMESPACE} + And ServiceAccount "olm-sa" with needed permissions is available in test namespace And ClusterExtension is applied """ apiVersion: olm.operatorframework.io/v1 diff --git a/test/e2e/steps/steps.go b/test/e2e/steps/steps.go index 131d5ccfe1..1f0bbb3ec5 100644 --- a/test/e2e/steps/steps.go +++ b/test/e2e/steps/steps.go @@ -101,6 +101,7 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ClusterObjectSet "([^"]+)" ref Secrets are immutable$`, ClusterObjectSetRefSecretsAreImmutable) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" ref Secrets are labeled with revision and owner$`, ClusterObjectSetRefSecretsLabeled) sc.Step(`^(?i)ClusterObjectSet "([^"]+)" ref Secrets have ownerReference to the revision$`, ClusterObjectSetRefSecretsHaveOwnerRef) + sc.Step(`^(?i)ClusterObjectSet "([^"]+)" ref Secrets have type "([^"]+)"$`, ClusterObjectSetReferredSecretsHaveType) sc.Step(`^(?i)resource "([^"]+)" is installed$`, ResourceAvailable) sc.Step(`^(?i)resource "([^"]+)" is available$`, ResourceAvailable) @@ -125,9 +126,8 @@ func RegisterSteps(sc *godog.ScenarioContext) { sc.Step(`^(?i)ServiceAccount "([^"]*)" with permissions to install extensions is available in "([^"]*)" namespace$`, ServiceAccountWithNeededPermissionsIsAvailableInGivenNamespace) sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in test namespace$`, ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace) - sc.Step(`^(?i)ServiceAccount "([^"]*)" with needed permissions is available in \${TEST_NAMESPACE}$`, ServiceAccountWithNeededPermissionsIsAvailableInTestNamespace) - sc.Step(`^(?i)ServiceAccount "([^"]*)" without create permissions is available in \${TEST_NAMESPACE}$`, ServiceAccountWithoutCreatePermissionsIsAvailableInTestNamespace) - sc.Step(`^(?i)ServiceAccount "([^"]*)" is available in \${TEST_NAMESPACE}$`, ServiceAccountIsAvailableInNamespace) + sc.Step(`^(?i)ServiceAccount "([^"]*)" without create permissions is available in test namespace$`, ServiceAccountWithoutCreatePermissionsIsAvailableInTestNamespace) + sc.Step(`^(?i)ServiceAccount "([^"]*)" is available in test namespace$`, ServiceAccountIsAvailableInNamespace) sc.Step(`^(?i)ServiceAccount "([^"]*)" in test namespace is cluster admin$`, ServiceAccountWithClusterAdminPermissionsIsAvailableInNamespace) sc.Step(`^(?i)ServiceAccount "([^"]+)" in test namespace has permissions to fetch "([^"]+)" metrics$`, ServiceAccountWithFetchMetricsPermissions) sc.Step(`^(?i)ServiceAccount "([^"]+)" sends request to "([^"]+)" endpoint of "([^"]+)" service$`, SendMetricsRequest) @@ -875,6 +875,28 @@ func ClusterObjectSetRefSecretsHaveOwnerRef(ctx context.Context, revisionName st return nil } +// ClusterObjectSetReferredSecretsHaveType verifies that all ref Secrets for the named +// ClusterObjectSet have the specified Secret type. +func ClusterObjectSetReferredSecretsHaveType(ctx context.Context, revisionName, expectedType string) error { + sc := scenarioCtx(ctx) + revisionName = substituteScenarioVars(strings.TrimSpace(revisionName), sc) + + secrets, err := listRefSecrets(ctx, revisionName) + if err != nil { + return err + } + if len(secrets) == 0 { + return fmt.Errorf("no ref Secrets found for revision %q", revisionName) + } + + for _, s := range secrets { + if string(s.Type) != expectedType { + return fmt.Errorf("secret %s/%s has type %q, expected %q", s.Namespace, s.Name, s.Type, expectedType) + } + } + return nil +} + // collectRefSecretNames returns the unique set of Secret names referenced by the ClusterObjectSet's phase objects. func collectRefSecretNames(ctx context.Context, revisionName string) ([]string, error) { var names []string diff --git a/vendor/modules.txt b/vendor/modules.txt index 67948dd3d2..fc347b0a9b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1974,7 +1974,7 @@ oras.land/oras-go/v2/registry/remote/credentials/trace oras.land/oras-go/v2/registry/remote/errcode oras.land/oras-go/v2/registry/remote/internal/errutil oras.land/oras-go/v2/registry/remote/retry -# pkg.package-operator.run/boxcutter v0.12.0 +# pkg.package-operator.run/boxcutter v0.13.0 ## explicit; go 1.25.3 pkg.package-operator.run/boxcutter pkg.package-operator.run/boxcutter/machinery @@ -2055,7 +2055,7 @@ sigs.k8s.io/controller-tools/pkg/internal/crd sigs.k8s.io/controller-tools/pkg/loader sigs.k8s.io/controller-tools/pkg/markers sigs.k8s.io/controller-tools/pkg/version -# sigs.k8s.io/crdify v0.5.0 +# sigs.k8s.io/crdify v0.5.1-0.20260309184313-54162f2e3097 ## explicit; go 1.24.0 sigs.k8s.io/crdify/pkg/config sigs.k8s.io/crdify/pkg/runner @@ -2066,6 +2066,7 @@ sigs.k8s.io/crdify/pkg/validations/crd/scope sigs.k8s.io/crdify/pkg/validations/crd/storedversionremoval sigs.k8s.io/crdify/pkg/validations/property sigs.k8s.io/crdify/pkg/validators/crd +sigs.k8s.io/crdify/pkg/validators/version sigs.k8s.io/crdify/pkg/validators/version/same sigs.k8s.io/crdify/pkg/validators/version/served # sigs.k8s.io/gateway-api v1.5.0 diff --git a/vendor/pkg.package-operator.run/boxcutter/machinery/objects.go b/vendor/pkg.package-operator.run/boxcutter/machinery/objects.go index da328c763e..ca9af76691 100644 --- a/vendor/pkg.package-operator.run/boxcutter/machinery/objects.go +++ b/vendor/pkg.package-operator.run/boxcutter/machinery/objects.go @@ -322,22 +322,27 @@ func (e *ObjectEngine) objectUpdateHandling( return nil, err } - // Ensure revision linearity. + // Get actual revision to ensure revision linearity actualObjectRevision, err := e.getObjectRevision(actualObject) if err != nil { return nil, fmt.Errorf("getting revision of object: %w", err) } - if actualObjectRevision > revision { - // Leave object alone. - // It's already owned by a later revision. - return newObjectResultProgressed( - actualObject, compareRes, options, - ), nil - } - switch ctrlSit { case ctrlSituationIsController: + // Ensure revision linearity. + // Only skip reconciliation for a newer revision when we are + // already the controller or a previous owner is the controller. + // For unknown or absent controllers, collision protection must + // be evaluated first. + if actualObjectRevision > revision { + // Leave object alone. + // It's already owned by a later revision. + return newObjectResultProgressed( + actualObject, compareRes, options, + ), nil + } + modified := compareRes.Comparison != nil && (!compareRes.Comparison.Modified.Empty() || !compareRes.Comparison.Removed.Empty()) @@ -440,6 +445,13 @@ func (e *ObjectEngine) objectUpdateHandling( // retain older revisions ownerReferences, // so they can still react to events. + // Ensure revision linearity + if actualObjectRevision > revision { + return newObjectResultProgressed( + actualObject, compareRes, options, + ), nil + } + // TODO: // ObjectResult ModifiedFields does not contain ownerReference changes // introduced here, this may lead to Updated Actions without modifications. diff --git a/vendor/pkg.package-operator.run/boxcutter/probing/fieldvalue.go b/vendor/pkg.package-operator.run/boxcutter/probing/fieldvalue.go new file mode 100644 index 0000000000..69d1e2ebfd --- /dev/null +++ b/vendor/pkg.package-operator.run/boxcutter/probing/fieldvalue.go @@ -0,0 +1,64 @@ +package probing + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// FieldValueProbe checks if the value found at FieldPath matches the provided Value. +type FieldValueProbe struct { + FieldPath, Value string +} + +var _ Prober = (*FieldValueProbe)(nil) + +// Probe executes the probe. +func (fv *FieldValueProbe) Probe(obj client.Object) Result { + return probeUnstructuredSingleMsg(obj, fv.probe) +} + +func (fv *FieldValueProbe) probe(obj *unstructured.Unstructured) Result { + fieldPath := strings.Split(strings.Trim(fv.FieldPath, "."), ".") + + fieldVal, ok, err := unstructured.NestedFieldCopy(obj.Object, fieldPath...) + if err != nil { + return Result{ + Status: StatusFalse, + Messages: []string{fmt.Sprintf(`error locating key %q; %v`, fv.FieldPath, err)}, + } + } + + if !ok { + return Result{ + Status: StatusFalse, + Messages: []string{fmt.Sprintf(`missing key: %q`, fv.FieldPath)}, + } + } + + if !equality.Semantic.DeepEqual(fieldVal, fv.Value) { + foundJSON, err := json.Marshal(fieldVal) + if err != nil { + foundJSON = []byte("") + } + + expectedJSON, err := json.Marshal(fv.Value) + if err != nil { + expectedJSON = []byte("") + } + + return Result{ + Status: StatusFalse, + Messages: []string{fmt.Sprintf(`value at key %q != %q; expected: %s got: %s`, fv.FieldPath, fv.Value, expectedJSON, foundJSON)}, + } + } + + return Result{ + Status: StatusTrue, + Messages: []string{fmt.Sprintf(`value at key %q == %q`, fv.FieldPath, fv.Value)}, + } +} diff --git a/vendor/sigs.k8s.io/crdify/pkg/runner/registry.go b/vendor/sigs.k8s.io/crdify/pkg/runner/registry.go index 4b4d59263a..5e56c6c17b 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/runner/registry.go +++ b/vendor/sigs.k8s.io/crdify/pkg/runner/registry.go @@ -42,6 +42,8 @@ func init() { property.RegisterRequired(defaultRegistry) property.RegisterType(defaultRegistry) property.RegisterDescription(defaultRegistry) + property.RegisterPattern(defaultRegistry) + property.RegisterNullable(defaultRegistry) } // DefaultRegistry returns a pre-configured validations.Registry. diff --git a/vendor/sigs.k8s.io/crdify/pkg/runner/results.go b/vendor/sigs.k8s.io/crdify/pkg/runner/results.go index 20f91dc22e..d345d98563 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/runner/results.go +++ b/vendor/sigs.k8s.io/crdify/pkg/runner/results.go @@ -18,10 +18,12 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "gopkg.in/yaml.v2" "sigs.k8s.io/crdify/pkg/validations" + "sigs.k8s.io/crdify/pkg/validators/version" ) // Results is a utility type to hold the validation results of @@ -35,13 +37,110 @@ type Results struct { // 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"` + SameVersionValidation []version.VersionedPropertyComparisonResult `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"` + ServedVersionValidation []version.VersionedPropertyComparisonResult `json:"servedVersionValidation,omitempty"` +} + +// MarshalJSON is a custom JSON marshalling function +// to ensure that we only include in the JSON/YAML rendered +// output the set of validations that returned some form +// of information (warnings/errors). +func (rr *Results) MarshalJSON() ([]byte, error) { + out := &struct { + CRDValidation []validations.ComparisonResult `json:"crdValidation,omitempty"` + SameVersionValidation []version.VersionedPropertyComparisonResult `json:"sameVersionValidation,omitempty"` + ServedVersionValidation []version.VersionedPropertyComparisonResult `json:"servedVersionValidation,omitempty"` + }{} + + out.CRDValidation = slices.DeleteFunc(rr.CRDValidation, func(e validations.ComparisonResult) bool { + return e.IsZero() + }) + slices.SortFunc(out.CRDValidation, func(a, b validations.ComparisonResult) int { + return strings.Compare(a.Name, b.Name) + }) + + out.SameVersionValidation = dropZeroVersionedPropertyComparisonResults(rr.SameVersionValidation...) + out.ServedVersionValidation = dropZeroVersionedPropertyComparisonResults(rr.ServedVersionValidation...) + + return json.Marshal(out) //nolint:wrapcheck +} + +func dropZeroVersionedPropertyComparisonResults(vpcrs ...version.VersionedPropertyComparisonResult) []version.VersionedPropertyComparisonResult { + out := []version.VersionedPropertyComparisonResult{} + + for _, vpcr := range vpcrs { + if vpcr.IsZero() { + continue + } + + pcrs := dropZeroPropertyComparisonResults(vpcr.PropertyComparisons...) + if len(pcrs) == 0 { + continue + } + + out = append(out, version.VersionedPropertyComparisonResult{ + Version: vpcr.Version, + PropertyComparisons: pcrs, + }) + } + + // sort for deterministic output + slices.SortFunc(out, func(a, b version.VersionedPropertyComparisonResult) int { + return strings.Compare(a.Version, b.Version) + }) + + return out +} + +func dropZeroPropertyComparisonResults(pcrs ...validations.PropertyComparisonResult) []validations.PropertyComparisonResult { + out := []validations.PropertyComparisonResult{} + + for _, pcr := range pcrs { + if pcr.IsZero() { + continue + } + + crs := dropZeroComparisonResults(pcr.ComparisonResults...) + if len(crs) == 0 { + continue + } + + out = append(out, validations.PropertyComparisonResult{ + Property: pcr.Property, + ComparisonResults: crs, + }) + } + + // sort for deterministic output + slices.SortFunc(out, func(a, b validations.PropertyComparisonResult) int { + return strings.Compare(a.Property, b.Property) + }) + + return out +} + +func dropZeroComparisonResults(crs ...validations.ComparisonResult) []validations.ComparisonResult { + out := []validations.ComparisonResult{} + + for _, cr := range crs { + if cr.IsZero() { + continue + } + + out = append(out, cr) + } + + // sort for deterministic output + slices.SortFunc(out, func(a, b validations.ComparisonResult) int { + return strings.Compare(a.Name, b.Name) + }) + + return out } // Format is a representation of an output format. @@ -97,159 +196,104 @@ func (rr *Results) RenderYAML() (string, error) { // RenderMarkdown returns a string of the results rendered as Markdown // //nolint:dupl -func (rr *Results) RenderMarkdown() string { //nolint:gocognit,cyclop +func (rr *Results) RenderMarkdown() string { 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 result.IsZero() { + continue } - if len(result.Warnings) > 0 { - for _, err := range result.Warnings { - out.WriteString(fmt.Sprintf("- **%s** - `WARNING` - %s\n", result.Name, err)) - } + for _, err := range result.Errors { + out.WriteString(fmt.Sprintf("- **%s** - `ERROR` - %s\n", result.Name, err)) } - if len(result.Errors) == 0 && len(result.Warnings) == 0 { - out.WriteString(fmt.Sprintf("- **%s** - ✓\n", result.Name)) + for _, err := range result.Warnings { + out.WriteString(fmt.Sprintf("- **%s** - `WARNING` - %s\n", result.Name, err)) } } - 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)) - } - } + processFunc := func(version, property string, comparisonResult validations.ComparisonResult) { + if comparisonResult.IsZero() { + return } - } - - 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)) - } - } + for _, err := range comparisonResult.Errors { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `ERROR` - %s\n", version, property, comparisonResult.Name, err)) + } - if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { - out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - ✓\n", version, property, propertyResult.Name)) - } - } + for _, err := range comparisonResult.Warnings { + out.WriteString(fmt.Sprintf("- **%s** - *%s* - %s - `WARNING` - %s\n", version, property, comparisonResult.Name, err)) } } + processVersionedComparisonResults(rr.SameVersionValidation, processFunc) + processVersionedComparisonResults(rr.ServedVersionValidation, processFunc) + return out.String() } // RenderPlainText returns a string of the results rendered as PlainText // //nolint:dupl -func (rr *Results) RenderPlainText() string { //nolint:gocognit,cyclop +func (rr *Results) RenderPlainText() string { 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 result.IsZero() { + continue } - if len(result.Warnings) > 0 { - for _, err := range result.Warnings { - out.WriteString(fmt.Sprintf("- %s - WARNING - %s\n", result.Name, err)) - } + for _, err := range result.Errors { + out.WriteString(fmt.Sprintf("- %s - ERROR - %s\n", result.Name, err)) } - if len(result.Errors) == 0 && len(result.Warnings) == 0 { - out.WriteString(fmt.Sprintf("- %s - ✓\n", result.Name)) + for _, err := range result.Warnings { + out.WriteString(fmt.Sprintf("- %s - WARNING - %s\n", result.Name, err)) } } - 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)) - } - } + processFunc := func(version, property string, comparisonResult validations.ComparisonResult) { + if comparisonResult.IsZero() { + return + } - 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)) - } - } + for _, err := range comparisonResult.Errors { + out.WriteString(fmt.Sprintf("- %s - %s - %s - ERROR - %s\n", version, property, comparisonResult.Name, err)) + } - if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { - out.WriteString(fmt.Sprintf("- %s - %s - %s - ✓\n", version, property, propertyResult.Name)) - } - } + for _, err := range comparisonResult.Warnings { + out.WriteString(fmt.Sprintf("- %s - %s - %s - WARNING - %s\n", version, property, comparisonResult.Name, err)) } } - out.WriteString("\n\n") - out.WriteString("Served Version Validations\n") + processVersionedComparisonResults(rr.SameVersionValidation, processFunc) + processVersionedComparisonResults(rr.ServedVersionValidation, processFunc) - 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)) - } - } + return out.String() +} - 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)) - } - } +func processVersionedComparisonResults(vcrs []version.VersionedPropertyComparisonResult, processFunc func(version, property string, comparisonResult validations.ComparisonResult)) { + // sort along the way for determinism + slices.SortFunc(vcrs, func(a, b version.VersionedPropertyComparisonResult) int { + return strings.Compare(a.Version, b.Version) + }) - if len(propertyResult.Errors) == 0 && len(propertyResult.Warnings) == 0 { - out.WriteString(fmt.Sprintf("- %s - %s - %s - ✓\n", version, property, propertyResult.Name)) - } + for _, versionResult := range vcrs { + slices.SortFunc(versionResult.PropertyComparisons, func(a, b validations.PropertyComparisonResult) int { + return strings.Compare(a.Property, b.Property) + }) + + for _, propertyResult := range versionResult.PropertyComparisons { + slices.SortFunc(propertyResult.ComparisonResults, func(a, b validations.ComparisonResult) int { + return strings.Compare(a.Name, b.Name) + }) + + for _, comparisonResult := range propertyResult.ComparisonResults { + processFunc(versionResult.Version, propertyResult.Property, comparisonResult) } } } - - return out.String() } // HasFailures returns a boolean signaling if any of the validation results contain any errors. @@ -271,8 +315,8 @@ func (rr *Results) HasCRDValidationFailures() bool { // 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 { + for _, propertyResults := range versionResults.PropertyComparisons { + for _, result := range propertyResults.ComparisonResults { if len(result.Errors) > 0 { return true } @@ -286,8 +330,8 @@ func (rr *Results) HasSameVersionValidationFailures() bool { // 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 { + for _, propertyResults := range versionResults.PropertyComparisons { + for _, result := range propertyResults.ComparisonResults { if len(result.Errors) > 0 { return true } diff --git a/vendor/sigs.k8s.io/crdify/pkg/validations/compare.go b/vendor/sigs.k8s.io/crdify/pkg/validations/compare.go index a231b89f32..88518925c3 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/validations/compare.go +++ b/vendor/sigs.k8s.io/crdify/pkg/validations/compare.go @@ -24,21 +24,49 @@ import ( "sigs.k8s.io/crdify/pkg/config" ) +// PropertyComparisonResult represents the results +// from running validations against a given property of +// a CustomResourceDefinition. +type PropertyComparisonResult struct { + // property is the property, represented + // as a simple JSON path, that the set of + // compatibility validation result applies to. + Property string `json:"property"` + + // comparisonResults is the set of compatibility + // validation results for the property. + ComparisonResults []ComparisonResult `json:"comparisonResults,omitempty"` +} + +// IsZero returns whether or not a PropertyComparisonResult +// contains any compatibility validation results. +// If it does contain validation results, it means +// that some incompatibility was found and an error or warning +// should be issued and returns `false`. +// If it does not contain validation results, +// no incompatibilities were found and returns `true`. +func (pcr PropertyComparisonResult) IsZero() bool { + return len(pcr.ComparisonResults) == 0 +} + // 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 { +func CompareVersions(a, b apiextensionsv1.CustomResourceDefinitionVersion, unhandledEnforcement config.EnforcementPolicy, comparators ...Comparator[apiextensionsv1.JSONSchemaProps]) []PropertyComparisonResult { oldFlattened := FlattenCRDVersion(a) newFlattened := FlattenCRDVersion(b) diffs := FlattenedCRDVersionDiff(oldFlattened, newFlattened) - result := map[string][]ComparisonResult{} + result := []PropertyComparisonResult{} for property, diff := range diffs { - result[property] = CompareProperties(diff.Old, diff.New, unhandledEnforcement, comparators...) + result = append(result, PropertyComparisonResult{ + Property: property, + ComparisonResults: CompareProperties(diff.Old, diff.New, unhandledEnforcement, comparators...), + }) } return result diff --git a/vendor/sigs.k8s.io/crdify/pkg/validations/property/nullable.go b/vendor/sigs.k8s.io/crdify/pkg/validations/property/nullable.go new file mode 100644 index 0000000000..4377b9434f --- /dev/null +++ b/vendor/sigs.k8s.io/crdify/pkg/validations/property/nullable.go @@ -0,0 +1,161 @@ +// 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" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/crdify/pkg/config" + "sigs.k8s.io/crdify/pkg/validations" +) + +var ( + _ validations.Validation = (*Nullable)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Nullable)(nil) +) + +const nullableValidationName = "nullable" + +// RegisterNullable registers the Nullable validation +// with the provided validation registry. +func RegisterNullable(registry validations.Registry) { + registry.Register(nullableValidationName, nullableFactory) +} + +// nullableFactory is a function used to initialize a Nullable validation +// implementation based on the provided configuration. +func nullableFactory(cfg map[string]interface{}) (validations.Validation, error) { + nullableCfg := &NullableConfig{} + + err := ConfigToType(cfg, nullableCfg) + if err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + err = ValidateNullableConfig(nullableCfg) + if err != nil { + return nil, fmt.Errorf("validating nullable config: %w", err) + } + + return &Nullable{NullableConfig: *nullableCfg}, nil +} + +// ValidateNullableConfig ensures provided NullableConfig is valid and defaults missing values. +func ValidateNullableConfig(in *NullableConfig) error { + if in == nil { + return nil + } + + switch in.AdditionPolicy { + case NullableAdditionPolicyAllow, NullableAdditionPolicyDisallow: + // valid entries + case NullableAdditionPolicy(""): + in.AdditionPolicy = NullableAdditionPolicyDisallow + default: + return fmt.Errorf("%w : %q (valid values: %q, %q)", errUnknownNullableAdditionPolicy, in.AdditionPolicy, NullableAdditionPolicyAllow, NullableAdditionPolicyDisallow) + } + + switch in.RemovalPolicy { + case NullableRemovalPolicyAllow, NullableRemovalPolicyDisallow: + // valid entries + case NullableRemovalPolicy(""): + in.RemovalPolicy = NullableRemovalPolicyDisallow + default: + return fmt.Errorf("%w : %q (valid values: %q, %q)", errUnknownNullableRemovalPolicy, in.RemovalPolicy, NullableRemovalPolicyAllow, NullableRemovalPolicyDisallow) + } + + return nil +} + +var errUnknownNullableAdditionPolicy = errors.New("unknown addition policy") +var errUnknownNullableRemovalPolicy = errors.New("unknown removal policy") + +// NullableAdditionPolicy represents how allowing null values should be evaluated. +type NullableAdditionPolicy string + +const ( + // NullableAdditionPolicyAllow treats allowing nulls when they were previously disallowed as compatible. + NullableAdditionPolicyAllow NullableAdditionPolicy = "Allow" + // NullableAdditionPolicyDisallow treats allowing nulls when they were previously disallowed as incompatible. + NullableAdditionPolicyDisallow NullableAdditionPolicy = "Disallow" +) + +// NullableRemovalPolicy represents how disallowing null values should be evaluated. +type NullableRemovalPolicy string + +const ( + // NullableRemovalPolicyAllow treats disallowing nulls when they were previously allowed as compatible. + NullableRemovalPolicyAllow NullableRemovalPolicy = "Allow" + // NullableRemovalPolicyDisallow treats disallowing nulls when they were previously allowed as incompatible. + NullableRemovalPolicyDisallow NullableRemovalPolicy = "Disallow" +) + +// NullableConfig contains additional configuration for the Nullable validation. +type NullableConfig struct { + // AdditionPolicy dictates whether allowing nulls when they were previously disallowed is compatible. + // Allowed values are Allow and Disallow. Defaults to Disallow. + AdditionPolicy NullableAdditionPolicy `json:"additionPolicy,omitempty"` + // RemovalPolicy dictates whether disallowing nulls when they were previously allowed is compatible. + // Allowed values are Allow and Disallow. Defaults to Disallow. + RemovalPolicy NullableRemovalPolicy `json:"removalPolicy,omitempty"` +} + +// Nullable is a Validation that can be used to identify +// incompatible changes to the nullable constraint of CRD properties. +type Nullable struct { + NullableConfig + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Nullable validation. +func (n *Nullable) Name() string { + return nullableValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Nullable validation. +func (n *Nullable) SetEnforcement(policy config.EnforcementPolicy) { + n.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the nullable constraint of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.Nullable field will be reset to 'false' 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 (n *Nullable) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + var err error + + switch { + case a.Nullable == b.Nullable: + // nothing to do + case !a.Nullable && b.Nullable && n.AdditionPolicy != NullableAdditionPolicyAllow: + err = fmt.Errorf("%w : %t -> %t", ErrNullableAllowed, a.Nullable, b.Nullable) + case a.Nullable && !b.Nullable && n.RemovalPolicy != NullableRemovalPolicyAllow: + err = fmt.Errorf("%w : %t -> %t", ErrNullableDisallowed, a.Nullable, b.Nullable) + } + + a.Nullable = false + b.Nullable = false + + return validations.HandleErrors(n.Name(), n.enforcement, err) +} + +// ErrNullableAllowed represents an error state when a property transitions from not nullable to nullable. +var ErrNullableAllowed = errors.New("nullable added") + +// ErrNullableDisallowed represents an error state when a property transitions from nullable to not nullable. +var ErrNullableDisallowed = errors.New("nullable removed") diff --git a/vendor/sigs.k8s.io/crdify/pkg/validations/property/pattern.go b/vendor/sigs.k8s.io/crdify/pkg/validations/property/pattern.go new file mode 100644 index 0000000000..1e3b2d7814 --- /dev/null +++ b/vendor/sigs.k8s.io/crdify/pkg/validations/property/pattern.go @@ -0,0 +1,143 @@ +// 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" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/crdify/pkg/config" + "sigs.k8s.io/crdify/pkg/validations" +) + +var ( + _ validations.Validation = (*Pattern)(nil) + _ validations.Comparator[apiextensionsv1.JSONSchemaProps] = (*Pattern)(nil) +) + +const patternValidationName = "pattern" + +// RegisterPattern registers the Pattern validation +// with the provided validation registry. +func RegisterPattern(registry validations.Registry) { + registry.Register(patternValidationName, patternFactory) +} + +// patternFactory is a function used to initialize a Pattern validation +// implementation based on the provided configuration. +func patternFactory(cfg map[string]interface{}) (validations.Validation, error) { + patternCfg := &PatternConfig{} + + err := ConfigToType(cfg, patternCfg) + if err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + err = ValidatePatternConfig(patternCfg) + if err != nil { + return nil, fmt.Errorf("validating pattern config: %w", err) + } + + return &Pattern{PatternConfig: *patternCfg}, nil +} + +// ValidatePatternConfig ensures provided PatternConfig is valid and defaults missing values. +func ValidatePatternConfig(in *PatternConfig) error { + if in == nil { + return nil + } + + switch in.RemovalPolicy { + case PatternRemovalPolicyAllow, PatternRemovalPolicyDisallow: + // valid entries + case PatternRemovalPolicy(""): + in.RemovalPolicy = PatternRemovalPolicyDisallow + default: + return fmt.Errorf("%w : %q (valid values: %q, %q)", errUnknownPatternRemovalPolicy, in.RemovalPolicy, PatternRemovalPolicyAllow, PatternRemovalPolicyDisallow) + } + + return nil +} + +var errUnknownPatternRemovalPolicy = errors.New("unknown removal policy") + +// PatternRemovalPolicy represents how removing a pattern constraint should be evaluated. +type PatternRemovalPolicy string + +const ( + // PatternRemovalPolicyAllow treats removing a pattern constraint as compatible. + PatternRemovalPolicyAllow PatternRemovalPolicy = "Allow" + // PatternRemovalPolicyDisallow treats removing a pattern constraint as incompatible. + PatternRemovalPolicyDisallow PatternRemovalPolicy = "Disallow" +) + +// PatternConfig contains additional configuration for the Pattern validation. +type PatternConfig struct { + // RemovalPolicy dictates whether removing a pattern constraint is compatible. + // Allowed values are Allow and Disallow. Defaults to Disallow. + RemovalPolicy PatternRemovalPolicy `json:"removalPolicy,omitempty"` +} + +// Pattern is a Validation that can be used to identify +// incompatible changes to the pattern constraints of CRD properties. +type Pattern struct { + PatternConfig + enforcement config.EnforcementPolicy +} + +// Name returns the name of the Pattern validation. +func (p *Pattern) Name() string { + return patternValidationName +} + +// SetEnforcement sets the EnforcementPolicy for the Pattern validation. +func (p *Pattern) SetEnforcement(policy config.EnforcementPolicy) { + p.enforcement = policy +} + +// Compare compares an old and a new JSONSchemaProps, checking for incompatible changes to the pattern constraints of a property. +// In order for callers to determine if diffs to a JSONSchemaProps have been handled by this validation +// the JSONSchemaProps.pattern 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 (p *Pattern) Compare(a, b *apiextensionsv1.JSONSchemaProps) validations.ComparisonResult { + var err error + + switch { + case a.Pattern == b.Pattern: + // nothing to do + case a.Pattern == "" && b.Pattern != "": + err = fmt.Errorf("%w : %q -> %q", ErrPatternAdded, a.Pattern, b.Pattern) + case a.Pattern != "" && b.Pattern == "" && p.RemovalPolicy != PatternRemovalPolicyAllow: + err = fmt.Errorf("%w : %q -> %q", ErrPatternRemoved, a.Pattern, b.Pattern) + case b.Pattern != "" && a.Pattern != b.Pattern: + err = fmt.Errorf("%w : %q -> %q", ErrPatternChanged, a.Pattern, b.Pattern) + } + + a.Pattern = "" + b.Pattern = "" + + return validations.HandleErrors(p.Name(), p.enforcement, err) +} + +// ErrPatternAdded represents an error state when a property Pattern was added. +var ErrPatternAdded = errors.New("pattern added") + +// ErrPatternChanged represents an error state when a property Pattern changed. +var ErrPatternChanged = errors.New("pattern changed") + +// ErrPatternRemoved represents an error state when a property Pattern was removed. +var ErrPatternRemoved = errors.New("pattern removed") diff --git a/vendor/sigs.k8s.io/crdify/pkg/validations/registry.go b/vendor/sigs.k8s.io/crdify/pkg/validations/registry.go index b1268bf753..dd05aead4b 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/validations/registry.go +++ b/vendor/sigs.k8s.io/crdify/pkg/validations/registry.go @@ -58,6 +58,14 @@ type ComparisonResult struct { Warnings []string `json:"warnings,omitempty"` } +// IsZero is a utility method used to +// identify if the ComparisonResult +// returns any information (warnings/errors) +// for its associated validation. +func (cr ComparisonResult) IsZero() bool { + return len(cr.Errors) == 0 && len(cr.Warnings) == 0 +} + // 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. diff --git a/vendor/sigs.k8s.io/crdify/pkg/validations/util.go b/vendor/sigs.k8s.io/crdify/pkg/validations/util.go index 85a5bb699d..d41336a8c7 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/validations/util.go +++ b/vendor/sigs.k8s.io/crdify/pkg/validations/util.go @@ -138,36 +138,36 @@ func SchemaHas(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *fiel nextAncestry := append(ancestry, s) if s.Items != nil { - if s.Items != nil && schemaHasRecurse(s.Items.Schema, fldPath.Child("items"), simpleLocation.Key("*"), nextAncestry, pred) { + if s.Items != nil && schemaHasRecurse(s.Items.Schema, fldPath.Child("items"), simpleLocation.Child("items"), 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) { + if schemaHasRecurse(&s.Items.JSONSchemas[i], fldPath.Child("items", "jsonSchemas").Index(i), simpleLocation.Child("items").Index(i), nextAncestry, pred) { return true } } } for i := range s.AllOf { - if schemaHasRecurse(&s.AllOf[i], fldPath.Child("allOf").Index(i), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(&s.AllOf[i], fldPath.Child("allOf").Index(i), simpleLocation.Child("allOf").Index(i), nextAncestry, pred) { return true } } for i := range s.AnyOf { - if schemaHasRecurse(&s.AnyOf[i], fldPath.Child("anyOf").Index(i), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(&s.AnyOf[i], fldPath.Child("anyOf").Index(i), simpleLocation.Child("anyOf").Index(i), nextAncestry, pred) { return true } } for i := range s.OneOf { - if schemaHasRecurse(&s.OneOf[i], fldPath.Child("oneOf").Index(i), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(&s.OneOf[i], fldPath.Child("oneOf").Index(i), simpleLocation.Child("oneOf").Index(i), nextAncestry, pred) { return true } } - if schemaHasRecurse(s.Not, fldPath.Child("not"), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(s.Not, fldPath.Child("not"), simpleLocation.Child("not"), nextAncestry, pred) { return true } @@ -178,31 +178,31 @@ func SchemaHas(s *apiextensionsv1.JSONSchemaProps, fldPath, simpleLocation *fiel } if s.AdditionalProperties != nil { - if schemaHasRecurse(s.AdditionalProperties.Schema, fldPath.Child("additionalProperties", "schema"), simpleLocation.Key("*"), nextAncestry, pred) { + if schemaHasRecurse(s.AdditionalProperties.Schema, fldPath.Child("additionalProperties", "schema"), simpleLocation.Child("additionalProperties"), nextAncestry, pred) { return true } } for patternName, s := range s.PatternProperties { - if schemaHasRecurse(&s, fldPath.Child("allOf").Key(patternName), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(&s, fldPath.Child("patternProperties").Key(patternName), simpleLocation.Child("patternProperties").Key(patternName), nextAncestry, pred) { return true } } if s.AdditionalItems != nil { - if schemaHasRecurse(s.AdditionalItems.Schema, fldPath.Child("additionalItems", "schema"), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(s.AdditionalItems.Schema, fldPath.Child("additionalItems", "schema"), simpleLocation.Child("additionalItems"), nextAncestry, pred) { return true } } - for _, s := range s.Definitions { - if schemaHasRecurse(&s, fldPath.Child("definitions"), simpleLocation, nextAncestry, pred) { + for definition, s := range s.Definitions { + if schemaHasRecurse(&s, fldPath.Child("definitions").Key(definition), simpleLocation.Child("definitions").Key(definition), nextAncestry, pred) { return true } } for dependencyName, d := range s.Dependencies { - if schemaHasRecurse(d.Schema, fldPath.Child("dependencies").Key(dependencyName).Child("schema"), simpleLocation, nextAncestry, pred) { + if schemaHasRecurse(d.Schema, fldPath.Child("dependencies").Key(dependencyName).Child("schema"), simpleLocation.Child("dependencies").Key(dependencyName), nextAncestry, pred) { return true } } diff --git a/vendor/sigs.k8s.io/crdify/pkg/validators/version/same/same.go b/vendor/sigs.k8s.io/crdify/pkg/validators/version/same/same.go index 1d4afe6a21..23bd961649 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/validators/version/same/same.go +++ b/vendor/sigs.k8s.io/crdify/pkg/validators/version/same/same.go @@ -18,6 +18,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "sigs.k8s.io/crdify/pkg/config" "sigs.k8s.io/crdify/pkg/validations" + "sigs.k8s.io/crdify/pkg/validators/version" ) // Validator validates Kubernetes CustomResourceDefinitions using the configured validations. @@ -64,8 +65,8 @@ func New(opts ...ValidatorOption) *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{} +func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) []version.VersionedPropertyComparisonResult { + result := []version.VersionedPropertyComparisonResult{} for _, oldVersion := range a.Spec.Versions { newVersion := validations.GetCRDVersionByName(b, oldVersion.Name) @@ -79,7 +80,10 @@ func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) map continue } - result[oldVersion.Name] = validations.CompareVersions(*oldVersion.DeepCopy(), *newVersion.DeepCopy(), v.unhandledEnforcement, v.comparators...) + result = append(result, version.VersionedPropertyComparisonResult{ + Version: oldVersion.Name, + PropertyComparisons: validations.CompareVersions(*oldVersion.DeepCopy(), *newVersion.DeepCopy(), v.unhandledEnforcement, v.comparators...), + }) } return result diff --git a/vendor/sigs.k8s.io/crdify/pkg/validators/version/served/served.go b/vendor/sigs.k8s.io/crdify/pkg/validators/version/served/served.go index bd6b4ff0de..9ccae0042b 100644 --- a/vendor/sigs.k8s.io/crdify/pkg/validators/version/served/served.go +++ b/vendor/sigs.k8s.io/crdify/pkg/validators/version/served/served.go @@ -22,6 +22,7 @@ import ( versionhelper "k8s.io/apimachinery/pkg/version" "sigs.k8s.io/crdify/pkg/config" "sigs.k8s.io/crdify/pkg/validations" + "sigs.k8s.io/crdify/pkg/validators/version" ) // Validator validates Kubernetes CustomResourceDefinitions using the configured validations. @@ -81,8 +82,8 @@ func New(opts ...ValidatorOption) *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{} +func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) []version.VersionedPropertyComparisonResult { + result := []version.VersionedPropertyComparisonResult{} // 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 { @@ -91,16 +92,18 @@ func (v *Validator) Validate(a, b *apiextensionsv1.CustomResourceDefinition) map aResults := v.compareVersionPairs(a) bResults := v.compareVersionPairs(b) - subtractExistingIssues(bResults, aResults) - return bResults + return subtractExistingIssues(bResults, aResults) } -func (v *Validator) compareVersionPairs(crd *apiextensionsv1.CustomResourceDefinition) map[string]map[string][]validations.ComparisonResult { - result := map[string]map[string][]validations.ComparisonResult{} +func (v *Validator) compareVersionPairs(crd *apiextensionsv1.CustomResourceDefinition) []version.VersionedPropertyComparisonResult { + result := []version.VersionedPropertyComparisonResult{} for resultVersionPair, versions := range makeVersionPairs(crd) { - result[resultVersionPair] = validations.CompareVersions(versions[0], versions[1], v.unhandledEnforcement, v.comparators...) + result = append(result, version.VersionedPropertyComparisonResult{ + Version: resultVersionPair, + PropertyComparisons: validations.CompareVersions(versions[0], versions[1], v.unhandledEnforcement, v.comparators...), + }) } return result @@ -142,64 +145,91 @@ func numUnidirectionalPermutations[T any](in []T) int { } // 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 +func subtractExistingIssues(b, a []version.VersionedPropertyComparisonResult) []version.VersionedPropertyComparisonResult { + out := []version.VersionedPropertyComparisonResult{} + + for _, versionedPropertyComparisonResult := range b { + ind := slices.IndexFunc(a, func(e version.VersionedPropertyComparisonResult) bool { + return e.Version == versionedPropertyComparisonResult.Version + }) + + // if the comparison result isn't found in the known set, + // keep it and continue looping. + if ind == -1 { + out = append(out, versionedPropertyComparisonResult) + continue } - return out + out = append(out, filterKnownIssuesForVersionedPropertyComparisonResult(versionedPropertyComparisonResult, a[ind])) + } + + return out +} + +func filterKnownIssuesForVersionedPropertyComparisonResult(b, a version.VersionedPropertyComparisonResult) version.VersionedPropertyComparisonResult { + return version.VersionedPropertyComparisonResult{ + Version: b.Version, + PropertyComparisons: filterKnownIssuesForPropertyComparisonResults(b.PropertyComparisons, a.PropertyComparisons), } +} + +func filterKnownIssuesForPropertyComparisonResults(b, a []validations.PropertyComparisonResult) []validations.PropertyComparisonResult { + out := []validations.PropertyComparisonResult{} + + for _, propertyComparisonResult := range b { + ind := slices.IndexFunc(a, func(e validations.PropertyComparisonResult) bool { + return e.Property == propertyComparisonResult.Property + }) - 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 + // if the comparison result isn't found in the known set, + // keep it and continue looping. + if ind == -1 { + out = append(out, propertyComparisonResult) 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 - } - } + out = append(out, filterKnownIssuesForPropertyComparisonResult(propertyComparisonResult, a[ind])) + } + + return out +} + +func filterKnownIssuesForPropertyComparisonResult(b, a validations.PropertyComparisonResult) validations.PropertyComparisonResult { + return validations.PropertyComparisonResult{ + Property: b.Property, + ComparisonResults: filterKnownIssuesForComparisonResults(b.ComparisonResults, a.ComparisonResults), + } +} + +func filterKnownIssuesForComparisonResults(b, a []validations.ComparisonResult) []validations.ComparisonResult { + out := []validations.ComparisonResult{} + + for _, compResult := range b { + ind := slices.IndexFunc(a, func(e validations.ComparisonResult) bool { + return e.Name == compResult.Name + }) + + // if the comparison result isn't found in the known set, + // keep it and continue looping. + if ind == -1 { + out = append(out, compResult) + continue } + + out = append(out, filterKnownIssuesForComparisonResult(compResult, a[ind])) + } + + return out +} + +func filterKnownIssuesForComparisonResult(b, a validations.ComparisonResult) validations.ComparisonResult { + return validations.ComparisonResult{ + Name: b.Name, + Errors: slices.DeleteFunc(b.Errors, func(e string) bool { + return slices.Contains(a.Errors, e) + }), + Warnings: slices.DeleteFunc(b.Warnings, func(e string) bool { + return slices.Contains(a.Warnings, e) + }), } } diff --git a/vendor/sigs.k8s.io/crdify/pkg/validators/version/types.go b/vendor/sigs.k8s.io/crdify/pkg/validators/version/types.go new file mode 100644 index 0000000000..2193abe4f7 --- /dev/null +++ b/vendor/sigs.k8s.io/crdify/pkg/validators/version/types.go @@ -0,0 +1,46 @@ +// 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 version + +import "sigs.k8s.io/crdify/pkg/validations" + +// VersionedPropertyComparisonResult represents the results +// from running validations against a given version of +// a CustomResourceDefinition. +type VersionedPropertyComparisonResult struct { + // version is the version(s) compared across the CustomResourceDefinitions + // that were validated. + // Version may be a singular version like "v1" when comparing + // the exact same versions, but it may also be + // a pair of versions like "v1 -> v2" when comparing + // compatibility of two different versions + // (i.e comparing served version backwards compatibility) + Version string `json:"version"` + + // propertyComparisons is the set of property-based + // validation results. + PropertyComparisons []validations.PropertyComparisonResult `json:"propertyComparisons,omitempty"` +} + +// IsZero returns whether or not a VersionedPropertyComparisonResult +// contains any property-based validation results. +// If it does contain property-based validation results, it means +// that some incompatibility was found and an error or warning +// should be issued and returns `false`. +// If it does not contain property-based validation results, +// no incompatibilities were found and returns `true`. +func (vpcr VersionedPropertyComparisonResult) IsZero() bool { + return len(vpcr.PropertyComparisons) == 0 +}