Repository: operator-framework/operator-controller (go)

Issue:
Add registry+v1 bundle validation framework
Add a small and extensible registry+v1 bundle validation framework. It should operate over convert.RegistryV1 object. To start with the following validators would be sufficient:

- ensure all strategy deployment specs have unique names
- ensure all CRDs have unique names
- ensure all owned CRDs are included in the bundle

Pull request:
:sparkles: Add registry+v1 bundle validation framework
# Description

Closes #1883  #1884

Adds a small registry+v1 bundle validation framework composed of individual validation functions. It is extensible by adding new validation functions. Note that the validation framework is _not_ currently used. This will happen in a follow up PR because some refactoring will be needed of the convert/rukpak package and I didn't want to bloat the PR.


## Reviewer Checklist

- [ ] API Go Documentation
- [ ] Tests: Unit Tests (and E2E Tests, if appropriate)
- [ ] Comprehensive Commit Messages
- [ ] Links to related GitHub Issue(s)


Patch:
diff --git a/internal/operator-controller/rukpak/convert/registryv1.go b/internal/operator-controller/rukpak/convert/registryv1.go
index 155b86b..ce599d4 100644
--- a/internal/operator-controller/rukpak/convert/registryv1.go
+++ b/internal/operator-controller/rukpak/convert/registryv1.go
@@ -13,6 +13,7 @@ import (
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
 	"k8s.io/apimachinery/pkg/runtime"
@@ -32,6 +33,7 @@ import (
 type RegistryV1 struct {
 	PackageName string
 	CSV         v1alpha1.ClusterServiceVersion
+	CRDs        []apiextensionsv1.CustomResourceDefinition
 	Others      []unstructured.Unstructured
 }
 
@@ -45,7 +47,7 @@ func RegistryV1ToHelmChart(rv1 fs.FS, installNamespace string, watchNamespace st
 		return nil, err
 	}
 
-	plain, err := Convert(reg, installNamespace, []string{watchNamespace})
+	plain, err := PlainConverter.Convert(reg, installNamespace, []string{watchNamespace})
 	if err != nil {
 		return nil, err
 	}
@@ -121,6 +123,12 @@ func ParseFS(rv1 fs.FS) (RegistryV1, error) {
 				}
 				reg.CSV = csv
 				foundCSV = true
+			case "CustomResourceDefinition":
+				crd := apiextensionsv1.CustomResourceDefinition{}
+				if err := runtime.DefaultUnstructuredConverter.FromUnstructured(info.Object.(*unstructured.Unstructured).Object, &crd); err != nil {
+					return err
+				}
+				reg.CRDs = append(reg.CRDs, crd)
 			default:
 				reg.Others = append(reg.Others, *info.Object.(*unstructured.Unstructured))
 			}
@@ -222,15 +230,23 @@ func saNameOrDefault(saName string) string {
 	return saName
 }
 
-func Convert(in RegistryV1, installNamespace string, targetNamespaces []string) (*Plain, error) {
+type Converter struct {
+	BundleValidator BundleValidator
+}
+
+func (c Converter) Convert(rv1 RegistryV1, installNamespace string, targetNamespaces []string) (*Plain, error) {
+	if err := c.BundleValidator.Validate(&rv1); err != nil {
+		return nil, err
+	}
+
 	if installNamespace == "" {
-		installNamespace = in.CSV.Annotations["operatorframework.io/suggested-namespace"]
+		installNamespace = rv1.CSV.Annotations["operatorframework.io/suggested-namespace"]
 	}
 	if installNamespace == "" {
-		installNamespace = fmt.Sprintf("%s-system", in.PackageName)
+		installNamespace = fmt.Sprintf("%s-system", rv1.PackageName)
 	}
 	supportedInstallModes := sets.New[string]()
-	for _, im := range in.CSV.Spec.InstallModes {
+	for _, im := range rv1.CSV.Spec.InstallModes {
 		if im.Supported {
 			supportedInstallModes.Insert(string(im.Type))
 		}
@@ -247,18 +263,18 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 		return nil, err
 	}
 
-	if len(in.CSV.Spec.APIServiceDefinitions.Owned) > 0 {
+	if len(rv1.CSV.Spec.APIServiceDefinitions.Owned) > 0 {
 		return nil, fmt.Errorf("apiServiceDefintions are not supported")
 	}
 
-	if len(in.CSV.Spec.WebhookDefinitions) > 0 {
+	if len(rv1.CSV.Spec.WebhookDefinitions) > 0 {
 		return nil, fmt.Errorf("webhookDefinitions are not supported")
 	}
 
 	deployments := []appsv1.Deployment{}
 	serviceAccounts := map[string]corev1.ServiceAccount{}
-	for _, depSpec := range in.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
-		annotations := util.MergeMaps(in.CSV.Annotations, depSpec.Spec.Template.Annotations)
+	for _, depSpec := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
+		annotations := util.MergeMaps(rv1.CSV.Annotations, depSpec.Spec.Template.Annotations)
 		annotations["olm.targetNamespaces"] = strings.Join(targetNamespaces, ",")
 		depSpec.Spec.Template.Annotations = annotations
 		deployments = append(deployments, appsv1.Deployment{
@@ -292,8 +308,8 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 	clusterRoles := []rbacv1.ClusterRole{}
 	clusterRoleBindings := []rbacv1.ClusterRoleBinding{}
 
-	permissions := in.CSV.Spec.InstallStrategy.StrategySpec.Permissions
-	clusterPermissions := in.CSV.Spec.InstallStrategy.StrategySpec.ClusterPermissions
+	permissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.Permissions
+	clusterPermissions := rv1.CSV.Spec.InstallStrategy.StrategySpec.ClusterPermissions
 	allPermissions := append(permissions, clusterPermissions...)
 
 	// Create all the service accounts
@@ -320,7 +336,7 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 	for _, ns := range targetNamespaces {
 		for _, permission := range permissions {
 			saName := saNameOrDefault(permission.ServiceAccountName)
-			name, err := generateName(fmt.Sprintf("%s-%s", in.CSV.Name, saName), permission)
+			name, err := generateName(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission)
 			if err != nil {
 				return nil, err
 			}
@@ -331,7 +347,7 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 
 	for _, permission := range clusterPermissions {
 		saName := saNameOrDefault(permission.ServiceAccountName)
-		name, err := generateName(fmt.Sprintf("%s-%s", in.CSV.Name, saName), permission)
+		name, err := generateName(fmt.Sprintf("%s-%s", rv1.CSV.Name, saName), permission)
 		if err != nil {
 			return nil, err
 		}
@@ -362,7 +378,10 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 		obj := obj
 		objs = append(objs, &obj)
 	}
-	for _, obj := range in.Others {
+	for _, obj := range rv1.CRDs {
+		objs = append(objs, &obj)
+	}
+	for _, obj := range rv1.Others {
 		obj := obj
 		supported, namespaced := registrybundle.IsSupported(obj.GetKind())
 		if !supported {
@@ -380,6 +399,10 @@ func Convert(in RegistryV1, installNamespace string, targetNamespaces []string)
 	return &Plain{Objects: objs}, nil
 }
 
+var PlainConverter = Converter{
+	BundleValidator: RegistryV1BundleValidator,
+}
+
 const maxNameLength = 63
 
 func generateName(base string, o interface{}) (string, error) {
diff --git a/internal/operator-controller/rukpak/convert/validator.go b/internal/operator-controller/rukpak/convert/validator.go
new file mode 100644
index 0000000..73060fa
--- /dev/null
+++ b/internal/operator-controller/rukpak/convert/validator.go
@@ -0,0 +1,89 @@
+package convert
+
+import (
+	"errors"
+	"fmt"
+	"slices"
+
+	"k8s.io/apimachinery/pkg/util/sets"
+)
+
+type BundleValidator []func(v1 *RegistryV1) []error
+
+func (v BundleValidator) Validate(rv1 *RegistryV1) error {
+	var errs []error
+	for _, validator := range v {
+		errs = append(errs, validator(rv1)...)
+	}
+	return errors.Join(errs...)
+}
+
+var RegistryV1BundleValidator = BundleValidator{
+	// NOTE: if you update this list, Test_BundleValidatorHasAllValidationFns will fail until
+	// you bring the same changes over to that test. This helps ensure all validation rules are executed
+	// while giving us the flexibility to test each validation function individually
+	CheckDeploymentSpecUniqueness,
+	CheckCRDResourceUniqueness,
+	CheckOwnedCRDExistence,
+}
+
+// CheckDeploymentSpecUniqueness checks that each strategy deployment spec in the csv has a unique name.
+// Errors are sorted by deployment name.
+func CheckDeploymentSpecUniqueness(rv1 *RegistryV1) []error {
+	deploymentNameSet := sets.Set[string]{}
+	duplicateDeploymentNames := sets.Set[string]{}
+	for _, dep := range rv1.CSV.Spec.InstallStrategy.StrategySpec.DeploymentSpecs {
+		if deploymentNameSet.Has(dep.Name) {
+			duplicateDeploymentNames.Insert(dep.Name)
+		}
+		deploymentNameSet.Insert(dep.Name)
+	}
+
+	//nolint:prealloc
+	var errs []error
+	for _, d := range slices.Sorted(slices.Values(duplicateDeploymentNames.UnsortedList())) {
+		errs = append(errs, fmt.Errorf("cluster service version contains duplicate strategy deployment spec '%s'", d))
+	}
+	return errs
+}
+
+// CheckOwnedCRDExistence checks bundle owned custom resource definitions declared in the csv exist in the bundle
+func CheckOwnedCRDExistence(rv1 *RegistryV1) []error {
+	crdsNames := sets.Set[string]{}
+	for _, crd := range rv1.CRDs {
+		crdsNames.Insert(crd.Name)
+	}
+
+	//nolint:prealloc
+	var errs []error
+	missingCRDNames := sets.Set[string]{}
+	for _, crd := range rv1.CSV.Spec.CustomResourceDefinitions.Owned {
+		if !crdsNames.Has(crd.Name) {
+			missingCRDNames.Insert(crd.Name)
+		}
+	}
+
+	for _, crdName := range slices.Sorted(slices.Values(missingCRDNames.UnsortedList())) {
+		errs = append(errs, fmt.Errorf("cluster service definition references owned custom resource definition '%s' not found in bundle", crdName))
+	}
+	return errs
+}
+
+// CheckCRDResourceUniqueness checks that the bundle CRD names are unique
+func CheckCRDResourceUniqueness(rv1 *RegistryV1) []error {
+	crdsNames := sets.Set[string]{}
+	duplicateCRDNames := sets.Set[string]{}
+	for _, crd := range rv1.CRDs {
+		if crdsNames.Has(crd.Name) {
+			duplicateCRDNames.Insert(crd.Name)
+		}
+		crdsNames.Insert(crd.Name)
+	}
+
+	//nolint:prealloc
+	var errs []error
+	for _, crdName := range slices.Sorted(slices.Values(duplicateCRDNames.UnsortedList())) {
+		errs = append(errs, fmt.Errorf("bundle contains duplicate custom resource definition '%s'", crdName))
+	}
+	return errs
+}


Test patch:
diff --git a/internal/operator-controller/rukpak/convert/registryv1_test.go b/internal/operator-controller/rukpak/convert/registryv1_test.go
index 20ea7fc..34f7722 100644
--- a/internal/operator-controller/rukpak/convert/registryv1_test.go
+++ b/internal/operator-controller/rukpak/convert/registryv1_test.go
@@ -1,6 +1,7 @@
 package convert_test
 
 import (
+	"errors"
 	"fmt"
 	"io/fs"
 	"os"
@@ -52,6 +53,24 @@ func getCsvAndService() (v1alpha1.ClusterServiceVersion, corev1.Service) {
 	return csv, svc
 }
 
+func TestConverterValidatesBundle(t *testing.T) {
+	converter := convert.Converter{
+		BundleValidator: []func(rv1 *convert.RegistryV1) []error{
+			func(rv1 *convert.RegistryV1) []error {
+				return []error{errors.New("test error")}
+			},
+		},
+	}
+
+	_, err := converter.Convert(convert.RegistryV1{}, "installNamespace", []string{"watchNamespace"})
+	require.Error(t, err)
+	require.Contains(t, err.Error(), "test error")
+}
+
+func TestPlainConverterUsedRegV1Validator(t *testing.T) {
+	require.Equal(t, convert.RegistryV1BundleValidator, convert.PlainConverter.BundleValidator)
+}
+
 func TestRegistryV1SuiteNamespaceNotAvailable(t *testing.T) {
 	var targetNamespaces []string
 
@@ -70,7 +89,7 @@ func TestRegistryV1SuiteNamespaceNotAvailable(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, targetNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, targetNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -104,7 +123,7 @@ func TestRegistryV1SuiteNamespaceAvailable(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, targetNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, targetNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -145,7 +164,7 @@ func TestRegistryV1SuiteNamespaceUnsupportedKind(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, targetNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, targetNamespaces)
 	require.Error(t, err)
 	require.ErrorContains(t, err, "bundle contains unsupported resource")
 	require.Nil(t, plainBundle)
@@ -179,7 +198,7 @@ func TestRegistryV1SuiteNamespaceClusterScoped(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, targetNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, targetNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -266,7 +285,7 @@ func TestRegistryV1SuiteGenerateAllNamespace(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -299,7 +318,7 @@ func TestRegistryV1SuiteGenerateMultiNamespace(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -332,7 +351,7 @@ func TestRegistryV1SuiteGenerateSingleNamespace(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -365,7 +384,7 @@ func TestRegistryV1SuiteGenerateOwnNamespace(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.NoError(t, err)
 
 	t.Log("By verifying if plain bundle has required objects")
@@ -470,7 +489,7 @@ func TestConvertInstallModeValidation(t *testing.T) {
 			}
 
 			t.Log("By converting to plain")
-			plainBundle, err := convert.Convert(registryv1Bundle, tc.installNamespace, tc.watchNamespaces)
+			plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, tc.installNamespace, tc.watchNamespaces)
 			require.Error(t, err)
 			require.Nil(t, plainBundle)
 		})
@@ -559,7 +578,7 @@ func TestRegistryV1SuiteGenerateNoWebhooks(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.Error(t, err)
 	require.ErrorContains(t, err, "webhookDefinitions are not supported")
 	require.Nil(t, plainBundle)
@@ -590,7 +609,7 @@ func TestRegistryV1SuiteGenerateNoAPISerciceDefinitions(t *testing.T) {
 	}
 
 	t.Log("By converting to plain")
-	plainBundle, err := convert.Convert(registryv1Bundle, installNamespace, watchNamespaces)
+	plainBundle, err := convert.PlainConverter.Convert(registryv1Bundle, installNamespace, watchNamespaces)
 	require.Error(t, err)
 	require.ErrorContains(t, err, "apiServiceDefintions are not supported")
 	require.Nil(t, plainBundle)
diff --git a/internal/operator-controller/rukpak/convert/validator_test.go b/internal/operator-controller/rukpak/convert/validator_test.go
new file mode 100644
index 0000000..7b218fb
--- /dev/null
+++ b/internal/operator-controller/rukpak/convert/validator_test.go
@@ -0,0 +1,224 @@
+package convert_test
+
+import (
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+	"github.com/operator-framework/api/pkg/operators/v1alpha1"
+
+	"github.com/operator-framework/operator-controller/internal/operator-controller/rukpak/convert"
+)
+
+func Test_BundleValidatorHasAllValidationFns(t *testing.T) {
+	expectedValidationFns := []func(v1 *convert.RegistryV1) []error{
+		convert.CheckDeploymentSpecUniqueness,
+		convert.CheckCRDResourceUniqueness,
+		convert.CheckOwnedCRDExistence,
+	}
+	actualValidationFns := convert.RegistryV1BundleValidator
+
+	require.Equal(t, len(expectedValidationFns), len(actualValidationFns))
+	for i := range expectedValidationFns {
+		require.Equal(t, reflect.ValueOf(expectedValidationFns[i]).Pointer(), reflect.ValueOf(actualValidationFns[i]).Pointer(), "bundle validator has unexpected validation function")
+	}
+}
+
+func Test_BundleValidatorCallsAllValidationFnsInOrder(t *testing.T) {
+	actual := ""
+	validator := convert.BundleValidator{
+		func(v1 *convert.RegistryV1) []error {
+			actual += "h"
+			return nil
+		},
+		func(v1 *convert.RegistryV1) []error {
+			actual += "i"
+			return nil
+		},
+	}
+	require.NoError(t, validator.Validate(nil))
+	require.Equal(t, "hi", actual)
+}
+
+func Test_CheckDeploymentSpecUniqueness(t *testing.T) {
+	for _, tc := range []struct {
+		name         string
+		bundle       *convert.RegistryV1
+		expectedErrs []error
+	}{
+		{
+			name: "accepts bundles with unique deployment strategy spec names",
+			bundle: &convert.RegistryV1{
+				CSV: makeCSV(
+					withStrategyDeploymentSpecs(
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"},
+					),
+				),
+			},
+		}, {
+			name: "rejects bundles with duplicate deployment strategy spec names",
+			bundle: &convert.RegistryV1{
+				CSV: makeCSV(
+					withStrategyDeploymentSpecs(
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-two"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-one"},
+					),
+				),
+			},
+			expectedErrs: []error{
+				errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-one'"),
+			},
+		}, {
+			name: "errors are ordered by deployment strategy spec name",
+			bundle: &convert.RegistryV1{
+				CSV: makeCSV(
+					withStrategyDeploymentSpecs(
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-c"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-b"},
+						v1alpha1.StrategyDeploymentSpec{Name: "test-deployment-a"},
+					),
+				),
+			},
+			expectedErrs: []error{
+				errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-a'"),
+				errors.New("cluster service version contains duplicate strategy deployment spec 'test-deployment-b'"),
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			errs := convert.CheckDeploymentSpecUniqueness(tc.bundle)
+			require.Equal(t, tc.expectedErrs, errs)
+		})
+	}
+}
+
+func Test_CRDResourceUniqueness(t *testing.T) {
+	for _, tc := range []struct {
+		name         string
+		bundle       *convert.RegistryV1
+		expectedErrs []error
+	}{
+		{
+			name: "accepts bundles with unique custom resource definition resources",
+			bundle: &convert.RegistryV1{
+				CRDs: []apiextensionsv1.CustomResourceDefinition{
+					{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+					{ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}},
+				},
+			},
+		}, {
+			name: "rejects bundles with duplicate custom resource definition resources",
+			bundle: &convert.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{
+				{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+				{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+			}},
+			expectedErrs: []error{
+				errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"),
+			},
+		}, {
+			name: "errors are ordered by custom resource definition name",
+			bundle: &convert.RegistryV1{CRDs: []apiextensionsv1.CustomResourceDefinition{
+				{ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}},
+				{ObjectMeta: metav1.ObjectMeta{Name: "c.crd.something"}},
+				{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+				{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+			}},
+			expectedErrs: []error{
+				errors.New("bundle contains duplicate custom resource definition 'a.crd.something'"),
+				errors.New("bundle contains duplicate custom resource definition 'c.crd.something'"),
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			err := convert.CheckCRDResourceUniqueness(tc.bundle)
+			require.Equal(t, tc.expectedErrs, err)
+		})
+	}
+}
+
+func Test_CheckOwnedCRDExistence(t *testing.T) {
+	for _, tc := range []struct {
+		name         string
+		bundle       *convert.RegistryV1
+		expectedErrs []error
+	}{
+		{
+			name: "accepts bundles with existing owned custom resource definition resources",
+			bundle: &convert.RegistryV1{
+				CRDs: []apiextensionsv1.CustomResourceDefinition{
+					{ObjectMeta: metav1.ObjectMeta{Name: "a.crd.something"}},
+					{ObjectMeta: metav1.ObjectMeta{Name: "b.crd.something"}},
+				},
+				CSV: makeCSV(
+					withOwnedCRDs(
+						v1alpha1.CRDDescription{Name: "a.crd.something"},
+						v1alpha1.CRDDescription{Name: "b.crd.something"},
+					),
+				),
+			},
+		}, {
+			name: "rejects bundles with missing owned custom resource definition resources",
+			bundle: &convert.RegistryV1{
+				CRDs: []apiextensionsv1.CustomResourceDefinition{},
+				CSV: makeCSV(
+					withOwnedCRDs(v1alpha1.CRDDescription{Name: "a.crd.something"}),
+				),
+			},
+			expectedErrs: []error{
+				errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"),
+			},
+		}, {
+			name: "errors are ordered by owned custom resource definition name",
+			bundle: &convert.RegistryV1{
+				CRDs: []apiextensionsv1.CustomResourceDefinition{},
+				CSV: makeCSV(
+					withOwnedCRDs(
+						v1alpha1.CRDDescription{Name: "a.crd.something"},
+						v1alpha1.CRDDescription{Name: "c.crd.something"},
+						v1alpha1.CRDDescription{Name: "b.crd.something"},
+					),
+				),
+			},
+			expectedErrs: []error{
+				errors.New("cluster service definition references owned custom resource definition 'a.crd.something' not found in bundle"),
+				errors.New("cluster service definition references owned custom resource definition 'b.crd.something' not found in bundle"),
+				errors.New("cluster service definition references owned custom resource definition 'c.crd.something' not found in bundle"),
+			},
+		},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			errs := convert.CheckOwnedCRDExistence(tc.bundle)
+			require.Equal(t, tc.expectedErrs, errs)
+		})
+	}
+}
+
+type csvOption func(version *v1alpha1.ClusterServiceVersion)
+
+func withStrategyDeploymentSpecs(strategyDeploymentSpecs ...v1alpha1.StrategyDeploymentSpec) csvOption {
+	return func(csv *v1alpha1.ClusterServiceVersion) {
+		csv.Spec.InstallStrategy.StrategySpec.DeploymentSpecs = strategyDeploymentSpecs
+	}
+}
+
+func withOwnedCRDs(crdDesc ...v1alpha1.CRDDescription) csvOption {
+	return func(csv *v1alpha1.ClusterServiceVersion) {
+		csv.Spec.CustomResourceDefinitions.Owned = crdDesc
+	}
+}
+
+func makeCSV(opts ...csvOption) v1alpha1.ClusterServiceVersion {
+	csv := v1alpha1.ClusterServiceVersion{}
+	for _, opt := range opts {
+		opt(&csv)
+	}
+	return csv
+}
diff --git a/test/convert/generate-manifests.go b/test/convert/generate-manifests.go
index 36d8595..c80d0c0 100644
--- a/test/convert/generate-manifests.go
+++ b/test/convert/generate-manifests.go
@@ -67,7 +67,7 @@ func generateManifests(outputPath, bundleDir, installNamespace, watchNamespace s
 	}
 
 	// Convert RegistryV1 to plain manifests
-	plain, err := convert.Convert(regv1, installNamespace, []string{watchNamespace})
+	plain, err := convert.PlainConverter.Convert(regv1, installNamespace, []string{watchNamespace})
 	if err != nil {
 		return fmt.Errorf("error converting registry+v1 bundle: %w", err)
 	}