From bcf0acee7715a9d4806fc8a931be95d7a15eb054 Mon Sep 17 00:00:00 2001 From: Ben Luddy Date: Fri, 25 Sep 2020 15:46:56 -0400 Subject: [PATCH] Annotate CSVs with the properties used during dependency resolution. This ensures that property information is not lost after an operator is installed, and that the properties of an operator are the same before and after installation. Preserving properties also allows installed operators to satisfy dependencies on properties that cannot be inferred from a ClusterServiceVersion spec, and is a step toward unifying installed and available operators from the perspective of resolution. --- .../crds/0000_50_olm_00-installplans.crd.yaml | 3 + pkg/controller/bundle/bundle_unpacker.go | 9 ++ pkg/controller/operators/catalog/manifests.go | 12 +- pkg/controller/operators/catalog/operator.go | 2 + pkg/controller/registry/resolver/cache.go | 32 +++- pkg/controller/registry/resolver/operators.go | 4 +- .../resolver/projection/properties.go | 51 +++++++ .../resolver/projection/properties_test.go | 141 ++++++++++++++++++ pkg/controller/registry/resolver/resolver.go | 31 +++- .../registry/resolver/resolver_test.go | 37 +++++ .../registry/resolver/step_resolver.go | 11 +- .../registry/resolver/step_resolver_test.go | 2 + pkg/controller/registry/resolver/steps.go | 13 +- test/e2e/subscription_e2e_test.go | 12 +- 14 files changed, 347 insertions(+), 13 deletions(-) create mode 100644 pkg/controller/registry/resolver/projection/properties.go create mode 100644 pkg/controller/registry/resolver/projection/properties_test.go diff --git a/deploy/chart/crds/0000_50_olm_00-installplans.crd.yaml b/deploy/chart/crds/0000_50_olm_00-installplans.crd.yaml index 0db2074343..1a837e1452 100644 --- a/deploy/chart/crds/0000_50_olm_00-installplans.crd.yaml +++ b/deploy/chart/crds/0000_50_olm_00-installplans.crd.yaml @@ -214,6 +214,9 @@ spec: description: Path refers to the location of a bundle to pull. It's typically an image reference. type: string + properties: + description: The effective properties of the unpacked bundle. + type: string replaces: description: Replaces is the name of the bundle to replace with the one found at Path. diff --git a/pkg/controller/bundle/bundle_unpacker.go b/pkg/controller/bundle/bundle_unpacker.go index 4348a6b3b4..6cbfc47ccd 100644 --- a/pkg/controller/bundle/bundle_unpacker.go +++ b/pkg/controller/bundle/bundle_unpacker.go @@ -21,6 +21,7 @@ import ( "github.com/operator-framework/api/pkg/operators/reference" operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" listersoperatorsv1alpha1 "github.com/operator-framework/operator-lifecycle-manager/pkg/api/client/listers/operators/v1alpha1" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" ) type BundleUnpackResult struct { @@ -355,6 +356,14 @@ func (c *ConfigMapUnpacker) UnpackBundle(lookup *operatorsv1alpha1.BundleLookup) return } + if result.BundleLookup.Properties != "" { + props, err := projection.PropertyListFromPropertiesAnnotation(lookup.Properties) + if err != nil { + return nil, fmt.Errorf("failed to load bundle properties for %q: %w", lookup.Identifier, err) + } + result.bundle.Properties = props + } + // A successful load should remove the pending condition result.RemoveCondition(operatorsv1alpha1.BundleLookupPending) diff --git a/pkg/controller/operators/catalog/manifests.go b/pkg/controller/operators/catalog/manifests.go index d3b4e03b28..9e8f12af51 100644 --- a/pkg/controller/operators/catalog/manifests.go +++ b/pkg/controller/operators/catalog/manifests.go @@ -11,6 +11,7 @@ import ( v1 "k8s.io/client-go/listers/core/v1" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" ) // ManifestResolver can dereference a manifest for a step. Steps may embed manifests directly or reference content @@ -49,7 +50,7 @@ func (r *manifestResolver) ManifestForStep(step *v1alpha1.Step) (string, error) log.WithField("ref", ref).Debug("step is a reference to configmap") usteps, err := r.unpackedStepsForBundle(step.Resolving, ref) - if err != nil { + if err != nil { return "", err } @@ -85,6 +86,15 @@ func (r *manifestResolver) unpackedStepsForBundle(bundleName string, ref *Unpack if err != nil { return nil, errorwrap.Wrapf(err, "error loading unpacked bundle configmap for ref %v", *ref) } + + if ref.Properties != "" { + props, err := projection.PropertyListFromPropertiesAnnotation(ref.Properties) + if err != nil { + return nil, fmt.Errorf("failed to load bundle properties for %q: %w", bundle.CsvName, err) + } + bundle.Properties = props + } + steps, err := resolver.NewStepResourceFromBundle(bundle, r.namespace, ref.Replaces, ref.CatalogSourceName, ref.CatalogSourceNamespace) if err != nil { return nil, errorwrap.Wrapf(err, "error calculating steps for ref %v", *ref) diff --git a/pkg/controller/operators/catalog/operator.go b/pkg/controller/operators/catalog/operator.go index 2eefd8a822..6cf6b2d927 100644 --- a/pkg/controller/operators/catalog/operator.go +++ b/pkg/controller/operators/catalog/operator.go @@ -1169,6 +1169,7 @@ type UnpackedBundleReference struct { CatalogSourceName string `json:"catalogSourceName"` CatalogSourceNamespace string `json:"catalogSourceNamespace"` Replaces string `json:"replaces"` + Properties string `json:"properties"` } // unpackBundles makes one walk through the bundlelookups and attempts to progress them @@ -1214,6 +1215,7 @@ func (o *Operator) unpackBundles(plan *v1alpha1.InstallPlan) (bool, *v1alpha1.In CatalogSourceName: res.CatalogSourceRef.Name, CatalogSourceNamespace: res.CatalogSourceRef.Namespace, Replaces: res.Replaces, + Properties: res.Properties, } r, err := json.Marshal(&ref) if err != nil { diff --git a/pkg/controller/registry/resolver/cache.go b/pkg/controller/registry/resolver/cache.go index cc40f691e2..7bea5e7425 100644 --- a/pkg/controller/registry/resolver/cache.go +++ b/pkg/controller/registry/resolver/cache.go @@ -436,6 +436,19 @@ func WithChannel(channel string) OperatorPredicate { func WithPackage(pkg string) OperatorPredicate { return func(o *Operator) bool { + for _, p := range o.Properties() { + if p.Type != opregistry.PackageType { + continue + } + var prop opregistry.PackageProperty + err := json.Unmarshal([]byte(p.Value), &prop) + if err != nil { + continue + } + if prop.PackageName == pkg { + return true + } + } return o.Package() == pkg } } @@ -443,7 +456,7 @@ func WithPackage(pkg string) OperatorPredicate { func WithoutDeprecatedProperty() OperatorPredicate { return func(o *Operator) bool { for _, p := range o.bundle.GetProperties() { - if p.GetType() == string(opregistry.DeprecatedType) { + if p.GetType() == opregistry.DeprecatedType { return false } } @@ -453,6 +466,23 @@ func WithoutDeprecatedProperty() OperatorPredicate { func WithVersionInRange(r semver.Range) OperatorPredicate { return func(o *Operator) bool { + for _, p := range o.Properties() { + if p.Type != opregistry.PackageType { + continue + } + var prop opregistry.PackageProperty + err := json.Unmarshal([]byte(p.Value), &prop) + if err != nil { + continue + } + ver, err := semver.Parse(prop.Version) + if err != nil { + continue + } + if r(ver) { + return true + } + } return o.version != nil && r(*o.version) } } diff --git a/pkg/controller/registry/resolver/operators.go b/pkg/controller/registry/resolver/operators.go index c41b0777d7..484d6532a3 100644 --- a/pkg/controller/registry/resolver/operators.go +++ b/pkg/controller/registry/resolver/operators.go @@ -260,14 +260,14 @@ func NewOperatorFromBundle(bundle *api.Bundle, startingCSV string, sourceKey reg // legacy support - if the api doesn't contain properties/dependencies, build them from required/provided apis properties := bundle.Properties - if properties == nil || len(properties) == 0 { + if len(properties) == 0 { properties, err = apisToProperties(provided) if err != nil { return nil, err } } dependencies := bundle.Dependencies - if dependencies == nil || len(dependencies) == 0 { + if len(dependencies) == 0 { dependencies, err = apisToDependencies(required) if err != nil { return nil, err diff --git a/pkg/controller/registry/resolver/projection/properties.go b/pkg/controller/registry/resolver/projection/properties.go new file mode 100644 index 0000000000..3cf7fbc8c3 --- /dev/null +++ b/pkg/controller/registry/resolver/projection/properties.go @@ -0,0 +1,51 @@ +package projection + +import ( + "encoding/json" + "fmt" + + "github.com/operator-framework/operator-registry/pkg/api" +) + +const ( + PropertiesAnnotationKey = "operatorframework.io/properties" +) + +type property struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` +} + +type propertiesAnnotation struct { + Properties []property `json:"properties,omitempty"` +} + +func PropertiesAnnotationFromPropertyList(props []*api.Property) (string, error) { + var anno propertiesAnnotation + for _, prop := range props { + anno.Properties = append(anno.Properties, property{ + Type: prop.Type, + Value: json.RawMessage(prop.Value), + }) + } + v, err := json.Marshal(&anno) + if err != nil { + return "", fmt.Errorf("failed to marshal properties annotation: %w", err) + } + return string(v), nil +} + +func PropertyListFromPropertiesAnnotation(raw string) ([]*api.Property, error) { + var anno propertiesAnnotation + if err := json.Unmarshal([]byte(raw), &anno); err != nil { + return nil, fmt.Errorf("failed to unmarshal properties annotation: %w", err) + } + var result []*api.Property + for _, each := range anno.Properties { + result = append(result, &api.Property{ + Type: each.Type, + Value: string(each.Value), + }) + } + return result, nil +} diff --git a/pkg/controller/registry/resolver/projection/properties_test.go b/pkg/controller/registry/resolver/projection/properties_test.go new file mode 100644 index 0000000000..6c71a82a7f --- /dev/null +++ b/pkg/controller/registry/resolver/projection/properties_test.go @@ -0,0 +1,141 @@ +package projection_test + +import ( + "testing" + + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" + "github.com/operator-framework/operator-registry/pkg/api" + "github.com/stretchr/testify/assert" +) + +func TestPropertiesAnnotationFromPropertyList(t *testing.T) { + for _, tc := range []struct { + name string + properties []*api.Property + expected string + error bool + }{ + { + name: "nil property slice", + properties: nil, + expected: "{}", + }, + { + name: "empty property slice", + properties: []*api.Property{}, + expected: "{}", + }, + { + name: "invalid property value", + properties: []*api.Property{{ + Type: "bad", + Value: `]`, + }}, + error: true, + }, + { + name: "nonempty property slice", + properties: []*api.Property{ + { + Type: "string", + Value: `"hello"`, + }, + { + Type: "number", + Value: `5`, + }, + { + Type: "array", + Value: `[1,"two",3,"four"]`, + }, { + Type: "object", + Value: `{"hello":{"worl":"d"}}`, + }, + }, + expected: `{"properties":[{"type":"string","value":"hello"},{"type":"number","value":5},{"type":"array","value":[1,"two",3,"four"]},{"type":"object","value":{"hello":{"worl":"d"}}}]}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual, err := projection.PropertiesAnnotationFromPropertyList(tc.properties) + assert := assert.New(t) + assert.Equal(tc.expected, actual) + if tc.error { + assert.Error(err) + } else { + assert.NoError(err) + } + }) + } +} + +func TestPropertyListFromPropertiesAnnotation(t *testing.T) { + for _, tc := range []struct { + name string + annotation string + expected []*api.Property + error bool + }{ + { + name: "empty", + annotation: "", + error: true, + }, + { + name: "invalid json", + annotation: "]", + error: true, + }, + { + name: "no properties key", + annotation: "{}", + expected: nil, + }, + { + name: "properties value not an array or null", + annotation: `{"properties":5}`, + error: true, + }, + { + name: "property element not an object", + annotation: `{"properties":[42]}`, + error: true, + }, + { + name: "no properties", + annotation: `{"properties":[]}`, + expected: nil, + }, + { + name: "several properties", + annotation: `{"properties":[{"type":"string","value":"hello"},{"type":"number","value":5},{"type":"array","value":[1,"two",3,"four"]},{"type":"object","value":{"hello":{"worl":"d"}}}]}`, + expected: []*api.Property{ + { + Type: "string", + Value: `"hello"`, + }, + { + Type: "number", + Value: `5`, + }, + { + Type: "array", + Value: `[1,"two",3,"four"]`, + }, { + Type: "object", + Value: `{"hello":{"worl":"d"}}`, + }, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + actual, err := projection.PropertyListFromPropertiesAnnotation(tc.annotation) + assert := assert.New(t) + assert.Equal(tc.expected, actual) + if tc.error { + assert.Error(err) + } else { + assert.NoError(err) + } + }) + } +} diff --git a/pkg/controller/registry/resolver/resolver.go b/pkg/controller/registry/resolver/resolver.go index 22ca5b3ff2..5955fcc3d0 100644 --- a/pkg/controller/registry/resolver/resolver.go +++ b/pkg/controller/registry/resolver/resolver.go @@ -12,6 +12,7 @@ import ( "github.com/operator-framework/api/pkg/operators/v1alpha1" v1alpha1listers "github.com/operator-framework/operator-lifecycle-manager/pkg/api/client/listers/operators/v1alpha1" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/solver" opregistry "github.com/operator-framework/operator-registry/pkg/registry" ) @@ -245,12 +246,14 @@ func (r *SatResolver) getSubscriptionInstallables(pkg, namespace string, current // track which operator this is replacing, so that it can be realized when creating the resources on cluster if current != nil { c.Replaces = current.Identifier() - // Until properties are projected onto CSVs, - // an installed operator can't be confidently - // folded into the existing package uniqueness - // constraints, so for the replacement case, a + // Package name can't be reliably inferred + // from a CSV without a projected package + // property, so for the replacement case, a // one-to-one conflict is created between the - // replacer and the replacee. + // replacer and the replacee. It should be + // safe to remove this conflict if properties + // annotations are made mandatory for + // resolution. c.AddConflict(bundleId(current.Identifier(), current.Channel(), registry.NewVirtualCatalogKey(namespace))) } depIds = append(depIds, c.Identifier()) @@ -373,6 +376,7 @@ func (r *SatResolver) newSnapshotForNamespace(namespace string, subs []*v1alpha1 } } } + var csvsMissingProperties []*v1alpha1.ClusterServiceVersion standaloneOperators := make([]*Operator, 0) for _, csv := range csvs { var constraints []solver.Constraint @@ -387,6 +391,15 @@ func (r *SatResolver) newSnapshotForNamespace(namespace string, subs []*v1alpha1 if err != nil { return nil, nil, err } + + if anno, ok := csv.GetAnnotations()[projection.PropertiesAnnotationKey]; !ok { + csvsMissingProperties = append(csvsMissingProperties, csv) + } else if props, err := projection.PropertyListFromPropertiesAnnotation(anno); err != nil { + return nil, nil, fmt.Errorf("failed to retrieve properties of csv %q: %w", csv.GetName(), err) + } else { + op.properties = props + } + op.sourceInfo = &OperatorSourceInfo{ Catalog: existingOperatorCatalog, } @@ -397,6 +410,14 @@ func (r *SatResolver) newSnapshotForNamespace(namespace string, subs []*v1alpha1 installables = append(installables, &i) } + if len(csvsMissingProperties) > 0 { + names := make([]string, len(csvsMissingProperties)) + for i, csv := range csvsMissingProperties { + names[i] = csv.GetName() + } + r.log.Infof("considered csvs without properties annotation during resolution: %v", names) + } + return NewRunningOperatorSnapshot(r.log, existingOperatorCatalog, standaloneOperators), installables, nil } diff --git a/pkg/controller/registry/resolver/resolver_test.go b/pkg/controller/registry/resolver/resolver_test.go index 87ced1ac2b..4f17bea971 100644 --- a/pkg/controller/registry/resolver/resolver_test.go +++ b/pkg/controller/registry/resolver/resolver_test.go @@ -61,6 +61,43 @@ func TestSolveOperators(t *testing.T) { require.EqualValues(t, expected, operators) } +func TestPropertiesAnnotationHonored(t *testing.T) { + const ( + namespace = "olm" + ) + community := registry.CatalogKey{"community", namespace} + + csv := existingOperator(namespace, "packageA.v1", "packageA", "alpha", "", nil, nil, nil, nil) + csv.Annotations = map[string]string{"operatorframework.io/properties": `{"properties":[{"type":"olm.package","value":{"packageName":"packageA","version":"1.0.0"}}]}`} + csvs := []*v1alpha1.ClusterServiceVersion{csv} + + sub := newSub(namespace, "packageB", "alpha", community) + subs := []*v1alpha1.Subscription{sub} + + b := genOperator("packageB.v1", "1.0.1", "", "packageB", "alpha", "community", "olm", nil, nil, []*api.Dependency{{Type: "olm.package", Value: `{"packageName":"packageA","version":"1.0.0"}`}}, "", false) + + fakeNamespacedOperatorCache := NamespacedOperatorCache{ + snapshots: map[registry.CatalogKey]*CatalogSnapshot{ + community: { + key: community, + operators: []*Operator{b}, + }, + }, + } + satResolver := SatResolver{ + cache: getFakeOperatorCache(fakeNamespacedOperatorCache), + log: logrus.New(), + } + + operators, err := satResolver.SolveOperators([]string{"olm"}, csvs, subs) + assert.NoError(t, err) + + expected := OperatorSet{ + "packageB.v1": b, + } + require.EqualValues(t, expected, operators) +} + func TestSolveOperators_MultipleChannels(t *testing.T) { APISet := APISet{opregistry.APIKey{"g", "v", "k", "ks"}: struct{}{}} Provides := APISet diff --git a/pkg/controller/registry/resolver/step_resolver.go b/pkg/controller/registry/resolver/step_resolver.go index 55a83a7514..cf9e75862a 100644 --- a/pkg/controller/registry/resolver/step_resolver.go +++ b/pkg/controller/registry/resolver/step_resolver.go @@ -19,6 +19,7 @@ import ( v1alpha1listers "github.com/operator-framework/operator-lifecycle-manager/pkg/api/client/listers/operators/v1alpha1" controllerbundle "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/bundle" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorlister" ) @@ -140,7 +141,7 @@ func (r *OperatorStepResolver) ResolveSteps(namespace string, _ SourceQuerier) ( } steps = append(steps, bundleSteps...) } else { - bundleLookups = append(bundleLookups, v1alpha1.BundleLookup{ + lookup := v1alpha1.BundleLookup{ Path: op.Bundle().GetBundlePath(), Identifier: op.Identifier(), Replaces: op.Replaces(), @@ -162,7 +163,13 @@ func (r *OperatorStepResolver) ResolveSteps(namespace string, _ SourceQuerier) ( Message: controllerbundle.JobNotStartedMessage, }, }, - }) + } + if anno, err := projection.PropertiesAnnotationFromPropertyList(op.Properties()); err != nil { + return nil, nil, nil, fmt.Errorf("failed to serialize operator properties for %q: %w", op.Identifier(), err) + } else { + lookup.Properties = anno + } + bundleLookups = append(bundleLookups, lookup) } if existingSubscription == nil { diff --git a/pkg/controller/registry/resolver/step_resolver_test.go b/pkg/controller/registry/resolver/step_resolver_test.go index 9a93174bc7..741067782b 100644 --- a/pkg/controller/registry/resolver/step_resolver_test.go +++ b/pkg/controller/registry/resolver/step_resolver_test.go @@ -181,6 +181,7 @@ func SharedResolverSpecs() []resolverTest { { Path: "quay.io/test/bundle@sha256:abcd", Identifier: "b.v1", + Properties: `{"properties":[{"type":"olm.gvk","value":{"group":"g","kind":"k","version":"v"}}]}`, CatalogSourceRef: &corev1.ObjectReference{ Namespace: catalog.Namespace, Name: catalog.Name, @@ -374,6 +375,7 @@ func SharedResolverSpecs() []resolverTest { Path: "quay.io/test/bundle@sha256:abcd", Identifier: "a.v2", Replaces: "a.v1", + Properties: `{"properties":[{"type":"olm.gvk","value":{"group":"g","kind":"k","version":"v"}}]}`, CatalogSourceRef: &corev1.ObjectReference{ Namespace: catalog.Namespace, Name: catalog.Name, diff --git a/pkg/controller/registry/resolver/steps.go b/pkg/controller/registry/resolver/steps.go index f6560d12e0..fb3d73e5a3 100644 --- a/pkg/controller/registry/resolver/steps.go +++ b/pkg/controller/registry/resolver/steps.go @@ -17,11 +17,12 @@ import ( k8sscheme "k8s.io/client-go/kubernetes/scheme" "github.com/operator-framework/api/pkg/operators/v1alpha1" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/ownerutil" ) const ( - secretKind = "Secret" + secretKind = "Secret" BundleSecretKind = "BundleSecret" ) @@ -118,6 +119,16 @@ func NewStepResourceFromBundle(bundle *api.Bundle, namespace, replaces, catalogS csv.SetNamespace(namespace) csv.Spec.Replaces = replaces + if anno, err := projection.PropertiesAnnotationFromPropertyList(bundle.Properties); err != nil { + return nil, fmt.Errorf("failed to construct properties annotation for %q: %w", csv.GetName(), err) + } else { + annos := csv.GetAnnotations() + if annos == nil { + annos = make(map[string]string) + } + annos[projection.PropertiesAnnotationKey] = anno + csv.SetAnnotations(annos) + } step, err := NewStepResourceFromObject(csv, catalogSourceName, catalogSourceNamespace) if err != nil { diff --git a/test/e2e/subscription_e2e_test.go b/test/e2e/subscription_e2e_test.go index 1f90e17635..dbb9d73939 100644 --- a/test/e2e/subscription_e2e_test.go +++ b/test/e2e/subscription_e2e_test.go @@ -31,9 +31,11 @@ import ( "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/install" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry" "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver" + "github.com/operator-framework/operator-lifecycle-manager/pkg/controller/registry/resolver/projection" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/comparison" "github.com/operator-framework/operator-lifecycle-manager/pkg/lib/operatorclient" "github.com/operator-framework/operator-lifecycle-manager/test/e2e/ctx" + registryapi "github.com/operator-framework/operator-registry/pkg/api" ) func Step(level int, text string, callbacks ...func()) { @@ -70,8 +72,16 @@ var _ = Describe("Subscription", func() { require.NoError(GinkgoT(), err) require.NotNil(GinkgoT(), subscription) - _, err = fetchCSV(crc, subscription.Status.CurrentCSV, testNamespace, buildCSVConditionChecker(v1alpha1.CSVPhaseSucceeded)) + csv, err := fetchCSV(crc, subscription.Status.CurrentCSV, testNamespace, buildCSVConditionChecker(v1alpha1.CSVPhaseSucceeded)) require.NoError(GinkgoT(), err) + + // Check for the olm.package property as a proxy for + // verifying that the annotation value is reasonable. + Expect( + projection.PropertyListFromPropertiesAnnotation(csv.GetAnnotations()["operatorframework.io/properties"]), + ).To(ContainElement( + ®istryapi.Property{Type: "olm.package", Value: `{"packageName":"myapp","version":"0.1.1"}`}, + )) }) // I. Creating a new subscription