Repository: operator-framework/operator-registry

Issue:
“opm index add” always fails with podman (only) with "unable to read directory bundle_tmpxxx/metadata" error
Issue summary

 “opm index add” works as expected with docker, but always fails with podman as it is unable to locate the bundle metadata directory. Note that the same error occurs with “opm registry add”. Version in use is opm 1.15.3.

 The issue appears to be that a file that should be there is not when the open call is made, however the clean-up routine successfully deletes the file that could not be found. The most likely reason for this disparity is the current working directory is different when trying to open the directory compared when the clean-up is triggered.

As the directory/file opens are all relative opens if the current working directory is incorrect then the directory won’t be found. It is therefore possible that the directory open and the clean-up run from different places in the file tree which would explain the reason that the directory can’t be opened but the clean-up can delete the directory.

An alternative could be related to a timing issue within the file system where the directory does not appear until after the first access is made but I think that this is less likely than the “open” simply being invoked from the wrong directory when running the open.

Unfortunately our golang code knowledge is not up to following the working directory in the “from” variable as it changes as we go through the various function calls. However, it’s interesting that the “from” address used to determine the base of the temp directory is set differently when an overwrite is performed than when a new index is created. This could be a possible source of the issue (around https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/registry/populator.go#L49 ).

Whatever the reason the file that is shown to be missing is definitely there as proven by the successful delete in the clean-up routine.

 

Command run:

 

$ opm index add --bundles docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1 --tag docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/catalog-index:v0.0.1  --container-tool podman --debug

INFO[0000] building the index                            bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

INFO[0000] running /usr/bin/podman pull docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1  bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

INFO[0002] running podman create                         bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

DEBU[0002] [podman create docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1 ]  bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

INFO[0002] running podman cp                             bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

DEBU[0002] [podman cp 6040f4da6cd5907e119fc929c22bf5c69a049edd9be5b30c7e3ca5d870abec94:/. bundle_tmp091974567]  bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

INFO[0002] running podman rm                             bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

DEBU[0002] [podman rm 6040f4da6cd5907e119fc929c22bf5c69a049edd9be5b30c7e3ca5d870abec94]  bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

DEBU[0002] unable to populate database: unable to read directory bundle_tmp091974567/metadata: open bundle_tmp091974567/metadata: no such file or directory  bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]"

ERRO[0002] permissive mode disabled                      bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]" error="unable to read directory bundle_tmp091974567/metadata: open bundle_tmp091974567/metadata: no such file or directory"

DEBU[0002] unable to add bundle to registry              bundles="[docker-cto-dev-local.artifactrepository.citigroup.net/cto-gfts-iscs-174063/memcached-bundle:v0.0.1]" error="unable to read directory bundle_tmp091974567/metadata: open bundle_tmp091974567/metadata: no such file or directory"

Error: unable to read directory bundle_tmp091974567/metadata: open bundle_tmp091974567/metadata: no such file or directory

Usage:

  opm index add [flags]

 

 

Code path:

 

https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/cmd/opm/index/add.go#L164

 

*** Main “index add” code entry point

 

func runIndexAddCmdFunc(cmd *cobra.Command, args []string) error {

      generate, err := cmd.Flags().GetBool("generate")

      if err != nil {

            return err

      }

 

      outDockerfile, err := cmd.Flags().GetString("out-dockerfile")

      if err != nil {

            return err

      }

 

      fromIndex, err := cmd.Flags().GetString("from-index")

      if err != nil {

            return err

      }

 

      bundles, err := cmd.Flags().GetStringSlice("bundles")

      if err != nil {

            return err

      }

 

      binaryImage, err := cmd.Flags().GetString("binary-image")

      if err != nil {

            return err

      }

 

      tag, err := cmd.Flags().GetString("tag")

      if err != nil {

            return err

      }

 

      permissive, err := cmd.Flags().GetBool("permissive")

      if err != nil {

            return err

      }

 

      skipTLS, err := cmd.Flags().GetBool("skip-tls")

      if err != nil {

            return err

      }

 

      mode, err := cmd.Flags().GetString("mode")

      if err != nil {

            return err

      }

 

      overwrite, err := cmd.Flags().GetBool("overwrite-latest")

      if err != nil {

            return err

      }

 

      modeEnum, err := registry.GetModeFromString(mode)

      if err != nil {

            return err

      }

 

      pullTool, buildTool, err := getContainerTools(cmd)

      if err != nil {

            return err

      }

 

      logger := logrus.WithFields(logrus.Fields{"bundles": bundles})

 

      logger.Info("building the index")

 

      indexAdder := indexer.NewIndexAdder(

            containertools.NewContainerTool(buildTool, containertools.PodmanTool),

            containertools.NewContainerTool(pullTool, containertools.NoneTool),

            logger)

 

      request := indexer.AddToIndexRequest{

            Generate:          generate,

            FromIndex:         fromIndex,

            BinarySourceImage: binaryImage,

            OutDockerfile:     outDockerfile,

            Tag:               tag,

            Bundles:           bundles,

            Permissive:        permissive,

            Mode:              modeEnum,

            SkipTLS:           skipTLS,

            Overwrite:         overwrite,

      }

 

      err = indexAdder.AddToIndex(request)

      if err != nil {

            return err

      }

 

      return nil

}

 

Calls AddToIndex in

https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/lib/indexer/indexer.go#L95

 

// AddToIndex is an aggregate API used to generate a registry index image with additional bundles

func (i ImageIndexer) AddToIndex(request AddToIndexRequest) error {

      buildDir, outDockerfile, cleanup, err := buildContext(request.Generate, request.OutDockerfile)

      defer cleanup()

      if err != nil {

            return err

      }

 

      databasePath, err := i.ExtractDatabase(buildDir, request.FromIndex, request.CaFile, request.SkipTLS)

      if err != nil {

            return err

      }

 

      // Run opm registry add on the database

      addToRegistryReq := registry.AddToRegistryRequest{

            Bundles:       request.Bundles,

            InputDatabase: databasePath,

            Permissive:    request.Permissive,

            Mode:          request.Mode,

            SkipTLS:       request.SkipTLS,

            ContainerTool: i.PullTool,

            Overwrite:     request.Overwrite,

      }

 

      // Add the bundles to the registry

      err = i.RegistryAdder.AddToRegistry(addToRegistryReq)

      if err != nil {

            i.Logger.WithError(err).Debugf("unable to add bundle to registry")

            return err

      }

 

Calls AddToRegistry in

https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/lib/registry/registry.go#L36

 

func (r RegistryUpdater) AddToRegistry(request AddToRegistryRequest) error {

      db, err := sqlite.Open(request.InputDatabase)

      if err != nil {

            return err

      }

      defer db.Close()

 

      dbLoader, err := sqlite.NewSQLLiteLoader(db)

      if err != nil {

            return err

      }

      if err := dbLoader.Migrate(context.TODO()); err != nil {

            return err

      }

 

…

 

      if err := populate(context.TODO(), dbLoader, graphLoader, dbQuerier, reg, simpleRefs, request.Mode, request.Overwrite); err != nil {

            r.Logger.Debugf("unable to populate database: %s", err)

 

            if !request.Permissive {

                  r.Logger.WithError(err).Error("permissive mode disabled")

                  return err

            }

            r.Logger.WithError(err).Warn("permissive mode enabled")

      }

 

AddToRegistry calls populate (in same go file). (https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/lib/registry/registry.go#L127

)

 

 

func populate(ctx context.Context, loader registry.Load, graphLoader registry.GraphLoader, querier registry.Query, reg image.Registry, refs []image.Reference, mode registry.Mode, overwrite bool) error {

      unpackedImageMap := make(map[image.Reference]string, 0)

      for _, ref := range refs {

            to, from, cleanup, err := unpackImage(ctx, reg, ref)

            if err != nil {

                  return err

            }

            unpackedImageMap[to] = from

            defer cleanup()

      }

 

      overwriteImageMap := make(map[string]map[image.Reference]string, 0)

      if overwrite {

            // find all bundles that are attempting to overwrite

…

      }

 

      populator := registry.NewDirectoryPopulator(loader, graphLoader, querier, unpackedImageMap, overwriteImageMap, overwrite)

      return populator.Populate(mode)

}

 

However as requests.Overwrite is False it runs the unpackImage code and falls through and calls NewDirectoryPopulator from the end of populate.

https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/registry/populator.go#L35

 

 

func NewDirectoryPopulator(loader Load, graphLoader GraphLoader, querier Query, imageDirMap map[image.Reference]string, overwriteDirMap map[string]map[image.Reference]string, overwrite bool) *DirectoryPopulator {

      return &DirectoryPopulator{

            loader:          loader,

            graphLoader:     graphLoader,

            querier:         querier,

            imageDirMap:     imageDirMap,

            overwriteDirMap: overwriteDirMap,

            overwrite:       overwrite,

      }

}

 

This in turn returns a DirectoryPopulator structure back to populate and “populate” executes “Populate” (with a capital “P”) https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/registry/populator.go#L46

 

func (i *DirectoryPopulator) Populate(mode Mode) error {

      var errs []error

      imagesToAdd := make([]*ImageInput, 0)

      for to, from := range i.imageDirMap {

            imageInput, err := NewImageInput(to, from)

            if err != nil {

                  errs = append(errs, err)

                  continue

            }

 

            imagesToAdd = append(imagesToAdd, imageInput)

      }

 

      imagesToReAdd := make([]*ImageInput, 0)

      for pkg := range i.overwriteDirMap {

            for to, from := range i.overwriteDirMap[pkg] {

                  imageInput, err := NewImageInput(to, from)

                  if err != nil {

                        errs = append(errs, err)

                        continue

                  }

 

                  imagesToReAdd = append(imagesToReAdd, imageInput)

            }

      }

 

      if len(errs) > 0 {

            return utilerrors.NewAggregate(errs)

      }

 

      err := i.loadManifests(imagesToAdd, imagesToReAdd, mode)

      if err != nil {

            return err

      }

 

      return nil

}

 

Calls NewImageInput in

https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/registry/imageinput.go#L24

 

Error at line 33, highlighted below, only place in source that this string appears and is the diagnostic error.

 

func NewImageInput(to image.Reference, from string) (*ImageInput, error) {

      var annotationsFound, dependenciesFound bool

      path := from

      manifests := filepath.Join(path, "manifests")

      metadata := filepath.Join(path, "metadata")

      // Get annotations file

      log := logrus.WithFields(logrus.Fields{"dir": from, "file": metadata, "load": "annotations"})

      files, err := ioutil.ReadDir(metadata)

      if err != nil {

            return nil, fmt.Errorf("unable to read directory %s: %s", metadata, err)

      }

 

      // Look for the metadata and manifests sub-directories to find the annotations.yaml

      // file that will inform how the manifests of the bundle should be loaded into the database.

      // If dependencies.yaml which contains operator dependencies in metadata directory

      // exists, parse and load it into the DB

      annotationsFile := &AnnotationsFile{}

      dependenciesFile := &DependenciesFile{}

…

 

Code path explains all error and debug statements found in the output other than docker command debug statements.

 

The code that triggers the podman pull and cp commands can be found in the “unpackImage” function https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/lib/registry/registry.go#L115

 

This triggers a pull and unpack from https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/image/execregistry/registry.go#L37 the unpack copies the docker container into the temporary directory nominated.

 

*************************************

 

We now look at the actual system calls and return status value from strace monitoring of the command and all sub-processes created.

 

Below is an extract from the strace logs focusing on the following system calls:

 

·       “openat” – Opens file relative to current working directory

·       “unlinkat” – Unlinnk (delete) file relative to current working directory

·       “getdents64” – Read directory contents

 

The highlighted entries show access to files in general and the metadata directory specifically. Access to the metadata directory fails immediately but the recursive delete which includes the metadata directory succeeds.

 

openat(AT_FDCWD, "bundle_tmp432811490/metadata", O_RDONLY|O_CLOEXEC) = -1 ENOENT

 (No such file or directory)

unlinkat(AT_FDCWD, "bundle_tmp432811490", 0) = -1 EISDIR (Is a directory)

unlinkat(AT_FDCWD, "bundle_tmp432811490", AT_REMOVEDIR) = -1 ENOTEMPTY (Director

y not empty)

openat(AT_FDCWD, ".", O_RDONLY|O_CLOEXEC) = 8

unlinkat(8, "bundle_tmp432811490", 0)   = -1 EISDIR (Is a directory)

openat(8, "bundle_tmp432811490", O_RDONLY|O_CLOEXEC) = 9

getdents64(9, [{d_ino=85343721, d_off=10, d_reclen=24, d_type=DT_DIR, d_name="."

}, {d_ino=25916239, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=4

2601538, d_off=512, d_reclen=32, d_type=DT_DIR, d_name="merged"}], 8192) = 80

getdents64(9, [], 8192)                 = 0

unlinkat(9, "merged", 0)                = -1 EISDIR (Is a directory)

openat(9, "merged", O_RDONLY|O_CLOEXEC) = 10

getdents64(10, [{d_ino=42601538, d_off=10, d_reclen=24, d_type=DT_DIR, d_name=".

"}, {d_ino=85343721, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=

59364218, d_off=15, d_reclen=32, d_type=DT_DIR, d_name="manifests"}, {d_ino=6782

4882, d_off=18, d_reclen=32, d_type=DT_DIR, d_name="metadata"}, {d_ino=77097626,

 d_off=512, d_reclen=32, d_type=DT_DIR, d_name="tests"}], 8192) = 144

getdents64(10, [], 8192)                = 0

unlinkat(10, "manifests", 0)            = -1 EISDIR (Is a directory)

openat(10, "manifests", O_RDONLY|O_CLOEXEC) = 11

getdents64(11, [{d_ino=59364218, d_off=10, d_reclen=24, d_type=DT_DIR, d_name=".

"}, {d_ino=42601538, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=

59364222, d_off=18, d_reclen=56, d_type=DT_REG, d_name="cache.example.com_memcac

heds.yaml"}, {d_ino=60093215, d_off=29, d_reclen=96, d_type=DT_REG, d_name="memc

ached-operator-controller-manager-metrics-service_v1_service.yaml"}, {d_ino=5937

0976, d_off=41, d_reclen=104, d_type=DT_REG, d_name="memcached-operator-metrics-

reader_rbac.authorization.k8s.io_v1_clusterrole.yaml"}, {d_ino=59370977, d_off=5

12, d_reclen=72, d_type=DT_REG, d_name="memcached-operator.clusterserviceversion

.yaml"}], 8192) = 376

getdents64(11, [], 8192)                = 0

unlinkat(11, "cache.example.com_memcacheds.yaml", 0) = 0

unlinkat(11, "memcached-operator-controller-manager-metrics-service_v1_service.y

aml", 0) = 0

unlinkat(11, "memcached-operator-metrics-reader_rbac.authorization.k8s.io_v1_clu

sterrole.yaml", 0) = 0

unlinkat(11, "memcached-operator.clusterserviceversion.yaml", 0) = 0

unlinkat(10, "manifests", AT_REMOVEDIR) = 0

unlinkat(10, "metadata", 0)             = -1 EISDIR (Is a directory)

openat(10, "metadata", O_RDONLY|O_CLOEXEC) = 11

getdents64(11, [{d_ino=67824882, d_off=10, d_reclen=24, d_type=DT_DIR, d_name=".

"}, {d_ino=42601538, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=

67824891, d_off=512, d_reclen=40, d_type=DT_REG, d_name="annotations.yaml"}], 81

92) = 88

getdents64(11, [], 8192)                = 0

unlinkat(11, "annotations.yaml", 0)     = 0

unlinkat(10, "metadata", AT_REMOVEDIR)  = 0

unlinkat(10, "tests", 0)                = -1 EISDIR (Is a directory)

openat(10, "tests", O_RDONLY|O_CLOEXEC) = 11

getdents64(11, [{d_ino=77097626, d_off=10, d_reclen=24, d_type=DT_DIR, d_name=".

"}, {d_ino=42601538, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=

85343725, d_off=512, d_reclen=32, d_type=DT_DIR, d_name="scorecard"}], 8192) = 8

0

getdents64(11, [], 8192)                = 0

unlinkat(11, "scorecard", 0)            = -1 EISDIR (Is a directory)

openat(11, "scorecard", O_RDONLY|O_CLOEXEC) = 12

getdents64(12, [{d_ino=85343725, d_off=10, d_reclen=24, d_type=DT_DIR, d_name=".

"}, {d_ino=77097626, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=

85343728, d_off=512, d_reclen=32, d_type=DT_REG, d_name="config.yaml"}], 8192) =

 80

getdents64(12, [], 8192)                = 0

unlinkat(12, "config.yaml", 0)          = 0

unlinkat(11, "scorecard", AT_REMOVEDIR) = 0

unlinkat(10, "tests", AT_REMOVEDIR)     = 0

unlinkat(9, "merged", AT_REMOVEDIR)     = 0

unlinkat(8, "bundle_tmp432811490", AT_REMOVEDIR) = 0

unlinkat(AT_FDCWD, "index_build_tmp268019669", 0) = -1 EISDIR (Is a directory)

unlinkat(AT_FDCWD, "index_build_tmp268019669", AT_REMOVEDIR) = -1 ENOTEMPTY (Dir

ectory not empty)

openat(AT_FDCWD, ".", O_RDONLY|O_CLOEXEC) = 7

unlinkat(7, "index_build_tmp268019669", 0) = -1 EISDIR (Is a directory)

openat(7, "index_build_tmp268019669", O_RDONLY|O_CLOEXEC) = 8

getdents64(8, [{d_ino=59085474, d_off=10, d_reclen=24, d_type=DT_DIR, d_name="."

}, {d_ino=25916239, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=7

7097622, d_off=512, d_reclen=32, d_type=DT_DIR, d_name="database"}], 8192) = 80

getdents64(8, [], 8192)                 = 0

unlinkat(8, "database", 0)              = -1 EISDIR (Is a directory)

openat(8, "database", O_RDONLY|O_CLOEXEC) = 9

getdents64(9, [{d_ino=77097622, d_off=10, d_reclen=24, d_type=DT_DIR, d_name="."

}, {d_ino=59085474, d_off=12, d_reclen=24, d_type=DT_DIR, d_name=".."}, {d_ino=7

7097624, d_off=512, d_reclen=32, d_type=DT_REG, d_name="index.db"}], 8192) = 80

getdents64(9, [], 8192)                 = 0

unlinkat(9, "index.db", 0)              = 0

unlinkat(8, "database", AT_REMOVEDIR)   = 0

unlinkat(7, "index_build_tmp268019669", AT_REMOVEDIR) = 0

unlinkat(AT_FDCWD, "index.Dockerfile905443632", 0) = 0

 

 

Steps

·       Tries to open directory “bundle_tmp432811490/metadata” but fails with “file not found”, see https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/registry/imageinput.go#L31

·       Does a recursive delete on bundle_tmp432811490 (error causes cascade of returns back to populate function which has created a “defer cleanup()” for when the populate call returns. This is defined within unpackImage found at https://github.com/operator-framework/operator-registry/blob/92a4c92168862d509841cef6fa949a1b6dec1ec9/pkg/lib/registry/registry.go#L115 )

·       Following recursive delete we delete the following files/directories under “bundle_tmp432811490”:

o   merged

o   manifests

§  cache.example.com_memcacheds.yaml

§  memcached-operator-controller-manager-metrics-service_v1_service.yaml

§  memcached-operator-metrics-reader_rbac.authorization.k8s.io_v1_clusterrole.yaml

§  memcached-operator.clusterserviceversion.yaml

o   metadata

§  annotations.yaml

 

 

 



Discussion:
@erdii: For me this fails with podman and docker as well.

```
$ opm index add --build-tool docker --bundles quay.io/erdii/empty-catalogsource-bundle:0.0.1 --tag quay.io/erdii/empty-catalogsource-index:0.0.1
INFO[0000] building the index                            bundles="[quay.io/erdii/empty-catalogsource-bundle:0.0.1]"
INFO[0001] Could not find optional dependencies file     dir=bundle_tmp419257694 file=bundle_tmp419257694/metadata load=annotations
ERRO[0001] permissive mode disabled                      bundles="[quay.io/erdii/empty-catalogsource-bundle:0.0.1]" error="unable to read directory bundle_tmp419257694/manifests: readdirent bundle_tmp419257694/manifests: not a directory"
Error: unable to read directory bundle_tmp419257694/manifests: readdirent bundle_tmp419257694/manifests: not a directory
Usage:
  opm index add [flags]
...
```

```
opm index add --build-tool podman --bundles quay.io/erdii/empty-catalogsource-bundle:0.0.1 --tag quay.io/erdii/empty-catalogsource-index:0.0.1
INFO[0000] building the index                            bundles="[quay.io/erdii/empty-catalogsource-bundle:0.0.1]"
INFO[0001] Could not find optional dependencies file     dir=bundle_tmp881236749 file=bundle_tmp881236749/metadata load=annotations
ERRO[0001] permissive mode disabled                      bundles="[quay.io/erdii/empty-catalogsource-bundle:0.0.1]" error="unable to read directory bundle_tmp881236749/manifests: readdirent bundle_tmp881236749/manifests: not a directory"
Error: unable to read directory bundle_tmp881236749/manifests: readdirent bundle_tmp881236749/manifests: not a directory
Usage:
  opm index add [flags]
...
```

The bundle image is available on quay though:

```
$ podman pull quay.io/erdii/empty-catalogsource-bundle:0.0.1Trying to pull quay.io/erdii/empty-catalogsource-bundle:0.0.1...
Getting image source signatures
Copying blob 726b914825f6 skipped: already exists  
Copying blob 7117aec6093a [--------------------------------------] 0.0b / 0.0b
Copying config 30460cf837 done  
Writing manifest to image destination
Storing signatures
30460cf83718b125f17e167758c1136a39bad346f3ca9555f936c8745f766ef7
```

Edit: forgot this:
```
$ opm version
Version: version.Version{OpmVersion:"v1.17.1", GitCommit:"42d8e42", BuildDate:"2021-05-18T20:21:11Z", GoOs:"linux", GoArch:"amd64"}
```

Edit2: __THE PROBLEM WAS ON MY SIDE!__ Explained in #668 maybe this is the root cause here as well? (Even though that should fail on docker as well)@github-actions: Issues go stale after 90 days of inactivity. If there is no further activity, the issue will be closed in another 30 days.@github-actions: This issue has been closed due to inactivity.

Solution patch:
diff --git a/cmd/opm/alpha/add/cmd.go b/cmd/opm/alpha/add/cmd.go
deleted file mode 100644
index f09b7842..00000000
--- a/cmd/opm/alpha/add/cmd.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package add
-
-import (
-	"fmt"
-	"time"
-
-	"github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
-
-	"github.com/operator-framework/operator-registry/pkg/action"
-	"github.com/operator-framework/operator-registry/pkg/image/containerdregistry"
-	"github.com/operator-framework/operator-registry/pkg/lib/certs"
-)
-
-const (
-	retryInterval = time.Second * 5
-	timeout       = time.Minute * 1
-)
-
-type add struct {
-	logger     *logrus.Entry
-	configsDir string
-	debug      bool
-	caFile     string
-	pullTool   string
-	skipTLS    bool
-}
-
-func NewCmd() *cobra.Command {
-	logger := logrus.New()
-	a := add{
-		logger:   logrus.NewEntry(logger),
-		pullTool: "none",
-	}
-	rootCmd := &cobra.Command{
-		Use:   "add <configs_path> <bundle_image1> <bundle_image2>........<bundle_imageN>",
-		Short: "add operator bundle/s to a catalog of packages",
-		Long:  `add operator bundles to a directory of configs representing packages in the catalog`,
-		Args:  cobra.MinimumNArgs(2),
-		PreRunE: func(cmd *cobra.Command, args []string) error {
-			a.configsDir = args[0]
-			if a.debug {
-				logger.SetLevel(logrus.DebugLevel)
-			}
-			return nil
-		},
-
-		RunE: a.addFunc,
-	}
-
-	rootCmd.Flags().BoolVar(&a.debug, "debug", false, "enable debug logging")
-	rootCmd.Flags().StringVarP(&a.caFile, "ca-file", "", "", "the root Certificates to use with this command")
-	rootCmd.Flags().BoolVar(&a.skipTLS, "skip-tls", false, "disable TLS verification")
-	return rootCmd
-}
-
-func (a *add) addFunc(cmd *cobra.Command, args []string) error {
-	rootCAs, err := certs.RootCAs(a.caFile)
-	if err != nil {
-		return fmt.Errorf("failed to get RootCAs: %v", err)
-	}
-	reg, err := containerdregistry.NewRegistry(containerdregistry.SkipTLS(a.skipTLS), containerdregistry.WithLog(a.logger), containerdregistry.WithRootCAs(rootCAs))
-	if err != nil {
-		return err
-	}
-	defer func() {
-		if err := reg.Destroy(); err != nil {
-			a.logger.Errorf("error destroying local cache: %v", err)
-		}
-	}()
-	bundles := []action.BundleExtractor{}
-	for _, ref := range args[1:] {
-		bundles = append(bundles, action.NewImageBundleExtractor(ref, reg, a.logger))
-	}
-	request := action.AddConfigRequest{
-		Bundles:    bundles,
-		ConfigsDir: a.configsDir,
-	}
-	adder := action.NewBundleAdder(a.logger)
-	return adder.AddToConfig(request)
-}
diff --git a/cmd/opm/alpha/cmd.go b/cmd/opm/alpha/cmd.go
index 6e2a82df..9c06ae2c 100644
--- a/cmd/opm/alpha/cmd.go
+++ b/cmd/opm/alpha/cmd.go
@@ -1,11 +1,13 @@
 package alpha
 
 import (
-	"github.com/operator-framework/operator-registry/cmd/opm/alpha/add"
+	"github.com/spf13/cobra"
+
 	"github.com/operator-framework/operator-registry/cmd/opm/alpha/bundle"
+	initcmd "github.com/operator-framework/operator-registry/cmd/opm/alpha/init"
+	"github.com/operator-framework/operator-registry/cmd/opm/alpha/render"
 	"github.com/operator-framework/operator-registry/cmd/opm/alpha/serve"
 	"github.com/operator-framework/operator-registry/cmd/opm/alpha/validate"
-	"github.com/spf13/cobra"
 )
 
 func NewCmd() *cobra.Command {
@@ -15,6 +17,6 @@ func NewCmd() *cobra.Command {
 		Short:  "Run an alpha subcommand",
 	}
 
-	runCmd.AddCommand(bundle.NewCmd(), add.NewCmd(), serve.NewCmd(), validate.NewCmd())
+	runCmd.AddCommand(bundle.NewCmd(), initcmd.NewCmd(), serve.NewCmd(), render.NewCmd(), validate.NewCmd())
 	return runCmd
 }
diff --git a/cmd/opm/alpha/init/cmd.go b/cmd/opm/alpha/init/cmd.go
new file mode 100644
index 00000000..7233b8e6
--- /dev/null
+++ b/cmd/opm/alpha/init/cmd.go
@@ -0,0 +1,77 @@
+package init
+
+import (
+	"io"
+	"os"
+
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+
+	"github.com/operator-framework/operator-registry/internal/action"
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+)
+
+func NewCmd() *cobra.Command {
+	var (
+		init            action.Init
+		iconFile        string
+		descriptionFile string
+		output          string
+	)
+	cmd := &cobra.Command{
+		Use:   "init <packageName>",
+		Short: "Generate an olm.package declarative config blob",
+		Args:  cobra.ExactArgs(1),
+		Run: func(cmd *cobra.Command, args []string) {
+			init.Package = args[0]
+
+			var write func(declcfg.DeclarativeConfig, io.Writer) error
+			switch output {
+			case "yaml":
+				write = declcfg.WriteYAML
+			case "json":
+				write = declcfg.WriteJSON
+			default:
+				log.Fatalf("invalid --output value %q, expected (json|yaml)", output)
+			}
+
+			if iconFile != "" {
+				iconReader, err := os.Open(iconFile)
+				if err != nil {
+					log.Fatalf("open icon file: %v", err)
+				}
+				defer closeReader(iconReader)
+				init.IconReader = iconReader
+			}
+
+			if descriptionFile != "" {
+				descriptionReader, err := os.Open(descriptionFile)
+				if err != nil {
+					log.Fatalf("open description file: %v", err)
+				}
+				defer closeReader(descriptionReader)
+				init.DescriptionReader = descriptionReader
+			}
+
+			pkg, err := init.Run()
+			if err != nil {
+				log.Fatal(err)
+			}
+			cfg := declcfg.DeclarativeConfig{Packages: []declcfg.Package{*pkg}}
+			if err := write(cfg, os.Stdout); err != nil {
+				log.Fatal(err)
+			}
+		},
+	}
+	cmd.Flags().StringVarP(&init.DefaultChannel, "default-channel", "c", "", "The channel that subscriptions will default to if unspecified")
+	cmd.Flags().StringVarP(&iconFile, "icon", "i", "", "Path to package's icon")
+	cmd.Flags().StringVarP(&descriptionFile, "description", "d", "", "Path to the operator's README.md (or other documentation)")
+	cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)")
+	return cmd
+}
+
+func closeReader(closer io.ReadCloser) {
+	if err := closer.Close(); err != nil {
+		log.Warn(err)
+	}
+}
diff --git a/cmd/opm/alpha/render/cmd.go b/cmd/opm/alpha/render/cmd.go
new file mode 100644
index 00000000..598eb030
--- /dev/null
+++ b/cmd/opm/alpha/render/cmd.go
@@ -0,0 +1,55 @@
+package render
+
+import (
+	"io"
+	"io/ioutil"
+	"log"
+	"os"
+
+	"github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+
+	"github.com/operator-framework/operator-registry/internal/action"
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+)
+
+func NewCmd() *cobra.Command {
+	var (
+		render action.Render
+		output string
+	)
+	cmd := &cobra.Command{
+		Use:   "render [index-image | bundle-image]...",
+		Short: "Generate declarative config blobs from the provided index and bundle images",
+		Args:  cobra.MinimumNArgs(1),
+		Run: func(cmd *cobra.Command, args []string) {
+			render.Refs = args
+
+			var write func(declcfg.DeclarativeConfig, io.Writer) error
+			switch output {
+			case "yaml":
+				write = declcfg.WriteYAML
+			case "json":
+				write = declcfg.WriteJSON
+			default:
+				log.Fatalf("invalid --output value %q, expected (json|yaml)", output)
+			}
+
+			// The bundle loading impl is somewhat verbose, even on the happy path,
+			// so discard all logrus default logger logs. Any important failures will be
+			// returned from render.Run and logged as fatal errors.
+			logrus.SetOutput(ioutil.Discard)
+
+			cfg, err := render.Run(cmd.Context())
+			if err != nil {
+				log.Fatal(err)
+			}
+
+			if err := write(*cfg, os.Stdout); err != nil {
+				log.Fatal(err)
+			}
+		},
+	}
+	cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format (json|yaml)")
+	return cmd
+}
diff --git a/docs/contributors/releases.md b/docs/contributors/releases.md
index eb800d00..5dcac082 100644
--- a/docs/contributors/releases.md
+++ b/docs/contributors/releases.md
@@ -13,7 +13,7 @@ Releases are triggered via tags. Make a new release by tagging a commit with an
 
 ## Checking the build
 
-Builds for a release can be found [on GitHub Actions](https://github.com/operator-framework/operator-registry/actions). After triggering a build, watch for logs. If the build is successful, a new [release](https://github.com/operator-framework/operator-registry/releases) should appear in GitHub.
+Builds for a release can be found [on GitHub Actions](https://github.com/operator-framework/operator-registry/actions). After triggering a build, watch for logs. If the build is successful, a new [release](https://github.com/operator-framework/operator-registry/releases) should appear in GitHub. It will be a draft release, so once all the artifacts are available you need to edit the release to publish the draft.
 
 ## Docker images
 
diff --git a/docs/design/operator-bundle.md b/docs/design/operator-bundle.md
index edf9d628..fb5eed17 100644
--- a/docs/design/operator-bundle.md
+++ b/docs/design/operator-bundle.md
@@ -106,7 +106,7 @@ LABEL operators.operatorframework.io.bundle.package.v1=test-operator
 LABEL operators.operatorframework.io.bundle.channels.v1=beta,stable
 LABEL operators.operatorframework.io.bundle.channel.default.v1=stable
 
-ADD test/*.yaml /manifests
+ADD test/*.yaml /manifests/
 ADD test/metadata/annotations.yaml /metadata/annotations.yaml
 ```
 
diff --git a/internal/action/init.go b/internal/action/init.go
new file mode 100644
index 00000000..335e70a1
--- /dev/null
+++ b/internal/action/init.go
@@ -0,0 +1,53 @@
+package action
+
+import (
+	"fmt"
+	"io"
+	"io/ioutil"
+
+	"github.com/h2non/filetype"
+
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+)
+
+type Init struct {
+	Package           string
+	DefaultChannel    string
+	DescriptionReader io.Reader
+	IconReader        io.Reader
+}
+
+func (i Init) Run() (*declcfg.Package, error) {
+	pkg := &declcfg.Package{
+		// TODO(joelanford): Use a constant for "olm.package"
+		Schema:         "olm.package",
+		Name:           i.Package,
+		DefaultChannel: i.DefaultChannel,
+	}
+	if i.DescriptionReader != nil {
+		descriptionData, err := ioutil.ReadAll(i.DescriptionReader)
+		if err != nil {
+			return nil, fmt.Errorf("read description: %v", err)
+		}
+		pkg.Description = string(descriptionData)
+	}
+
+	if i.IconReader != nil {
+		iconData, err := ioutil.ReadAll(i.IconReader)
+		if err != nil {
+			return nil, fmt.Errorf("read icon: %v", err)
+		}
+		iconType, err := filetype.Match(iconData)
+		if err != nil {
+			return nil, fmt.Errorf("detect icon mediatype: %v", err)
+		}
+		if iconType.MIME.Type != "image" {
+			return nil, fmt.Errorf("detected invalid type %q: not an image", iconType.MIME.Value)
+		}
+		pkg.Icon = &declcfg.Icon{
+			Data:      iconData,
+			MediaType: iconType.MIME.Value,
+		}
+	}
+	return pkg, nil
+}
diff --git a/internal/action/render.go b/internal/action/render.go
new file mode 100644
index 00000000..cd061c22
--- /dev/null
+++ b/internal/action/render.go
@@ -0,0 +1,240 @@
+package action
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/sirupsen/logrus"
+	"k8s.io/apimachinery/pkg/util/sets"
+
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+	"github.com/operator-framework/operator-registry/internal/property"
+	"github.com/operator-framework/operator-registry/pkg/containertools"
+	"github.com/operator-framework/operator-registry/pkg/image"
+	"github.com/operator-framework/operator-registry/pkg/image/containerdregistry"
+	"github.com/operator-framework/operator-registry/pkg/lib/bundle"
+	"github.com/operator-framework/operator-registry/pkg/registry"
+	"github.com/operator-framework/operator-registry/pkg/sqlite"
+)
+
+type Render struct {
+	Refs     []string
+	Registry image.Registry
+}
+
+func nullLogger() *logrus.Entry {
+	logger := logrus.New()
+	logger.SetOutput(ioutil.Discard)
+	return logrus.NewEntry(logger)
+}
+
+func (r Render) Run(ctx context.Context) (*declcfg.DeclarativeConfig, error) {
+	if r.Registry == nil {
+		reg, err := r.createRegistry()
+		if err != nil {
+			return nil, fmt.Errorf("create registry: %v", err)
+		}
+		defer reg.Destroy()
+		r.Registry = reg
+	}
+
+	var cfgs []declcfg.DeclarativeConfig
+	for _, ref := range r.Refs {
+		var (
+			cfg *declcfg.DeclarativeConfig
+			err error
+		)
+		// TODO(joelanford): Add support for detecting and rendering sqlite files.
+		if stat, serr := os.Stat(ref); serr == nil && stat.IsDir() {
+			cfg, err = declcfg.LoadDir(ref)
+		} else {
+			cfg, err = r.imageToDeclcfg(ctx, ref)
+		}
+		if err != nil {
+			return nil, fmt.Errorf("render reference %q: %v", ref, err)
+		}
+		renderBundleObjects(cfg)
+		cfgs = append(cfgs, *cfg)
+	}
+
+	return combineConfigs(cfgs), nil
+}
+
+func (r Render) createRegistry() (*containerdregistry.Registry, error) {
+	cacheDir, err := os.MkdirTemp("", "render-registry-")
+	if err != nil {
+		return nil, fmt.Errorf("create tempdir: %v", err)
+	}
+
+	reg, err := containerdregistry.NewRegistry(
+		containerdregistry.WithCacheDir(cacheDir),
+
+		// The containerd registry impl is somewhat verbose, even on the happy path,
+		// so discard all logger logs. Any important failures will be returned from
+		// registry methods and eventually logged as fatal errors.
+		containerdregistry.WithLog(nullLogger()),
+	)
+	if err != nil {
+		return nil, err
+	}
+	return reg, nil
+}
+
+func (r Render) imageToDeclcfg(ctx context.Context, imageRef string) (*declcfg.DeclarativeConfig, error) {
+	ref := image.SimpleReference(imageRef)
+	if err := r.Registry.Pull(ctx, ref); err != nil {
+		return nil, err
+	}
+	labels, err := r.Registry.Labels(ctx, ref)
+	if err != nil {
+		return nil, err
+	}
+	tmpDir, err := ioutil.TempDir("", "render-unpack-")
+	if err != nil {
+		return nil, err
+	}
+	defer os.RemoveAll(tmpDir)
+	if err := r.Registry.Unpack(ctx, ref, tmpDir); err != nil {
+		return nil, err
+	}
+
+	var cfg *declcfg.DeclarativeConfig
+	if dbFile, ok := labels[containertools.DbLocationLabel]; ok {
+		cfg, err = sqliteToDeclcfg(ctx, filepath.Join(tmpDir, dbFile))
+		if err != nil {
+			return nil, err
+		}
+	} else if configsDir, ok := labels["operators.operatorframework.io.index.configs.v1"]; ok {
+		// TODO(joelanford): Make a constant for above configs location label
+		cfg, err = declcfg.LoadDir(filepath.Join(tmpDir, configsDir))
+		if err != nil {
+			return nil, err
+		}
+	} else if _, ok := labels[bundle.PackageLabel]; ok {
+		img, err := registry.NewImageInput(ref, tmpDir)
+		if err != nil {
+			return nil, err
+		}
+
+		cfg, err = bundleToDeclcfg(img.Bundle)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		labelKeys := sets.StringKeySet(labels)
+		labelVals := []string{}
+		for _, k := range labelKeys.List() {
+			labelVals = append(labelVals, fmt.Sprintf("  %s=%s", k, labels[k]))
+		}
+		if len(labelVals) > 0 {
+			return nil, fmt.Errorf("render %q: image type could not be determined, found labels\n%s", ref, strings.Join(labelVals, "\n"))
+		} else {
+			return nil, fmt.Errorf("render %q: image type could not be determined: image has no labels", ref)
+		}
+	}
+	return cfg, nil
+}
+
+func sqliteToDeclcfg(ctx context.Context, dbFile string) (*declcfg.DeclarativeConfig, error) {
+	db, err := sqlite.Open(dbFile)
+	if err != nil {
+		return nil, err
+	}
+
+	migrator, err := sqlite.NewSQLLiteMigrator(db)
+	if err != nil {
+		return nil, err
+	}
+	if migrator == nil {
+		return nil, fmt.Errorf("failed to load migrator")
+	}
+
+	if err := migrator.Migrate(ctx); err != nil {
+		return nil, err
+	}
+
+	q := sqlite.NewSQLLiteQuerierFromDb(db)
+	m, err := sqlite.ToModel(ctx, q)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg := declcfg.ConvertFromModel(m)
+	return &cfg, nil
+}
+
+func bundleToDeclcfg(bundle *registry.Bundle) (*declcfg.DeclarativeConfig, error) {
+	bundleProperties, err := registry.PropertiesFromBundle(bundle)
+	if err != nil {
+		return nil, fmt.Errorf("get properties for bundle %q: %v", bundle.Name, err)
+	}
+	relatedImages, err := getRelatedImages(bundle)
+	if err != nil {
+		return nil, fmt.Errorf("get related images for bundle %q: %v", bundle.Name, err)
+	}
+
+	dBundle := declcfg.Bundle{
+		Schema:        "olm.bundle",
+		Name:          bundle.Name,
+		Package:       bundle.Package,
+		Image:         bundle.BundleImage,
+		Properties:    bundleProperties,
+		RelatedImages: relatedImages,
+	}
+
+	return &declcfg.DeclarativeConfig{Bundles: []declcfg.Bundle{dBundle}}, nil
+}
+
+func getRelatedImages(b *registry.Bundle) ([]declcfg.RelatedImage, error) {
+	csv, err := b.ClusterServiceVersion()
+	if err != nil {
+		return nil, err
+	}
+
+	var objmap map[string]*json.RawMessage
+	if err = json.Unmarshal(csv.Spec, &objmap); err != nil {
+		return nil, err
+	}
+
+	rawValue, ok := objmap["relatedImages"]
+	if !ok || rawValue == nil {
+		return nil, err
+	}
+
+	var relatedImages []declcfg.RelatedImage
+	if err = json.Unmarshal(*rawValue, &relatedImages); err != nil {
+		return nil, err
+	}
+	return relatedImages, nil
+}
+
+func renderBundleObjects(cfg *declcfg.DeclarativeConfig) {
+	for bi, b := range cfg.Bundles {
+		props := b.Properties[:0]
+		for _, p := range b.Properties {
+			if p.Type != property.TypeBundleObject {
+				props = append(props, p)
+			}
+		}
+
+		for _, obj := range b.Objects {
+			props = append(props, property.MustBuildBundleObjectData([]byte(obj)))
+		}
+		cfg.Bundles[bi].Properties = props
+	}
+}
+
+func combineConfigs(cfgs []declcfg.DeclarativeConfig) *declcfg.DeclarativeConfig {
+	out := &declcfg.DeclarativeConfig{}
+	for _, in := range cfgs {
+		out.Packages = append(out.Packages, in.Packages...)
+		out.Bundles = append(out.Bundles, in.Bundles...)
+		out.Others = append(out.Others, in.Others...)
+	}
+	return out
+}
diff --git a/internal/declcfg/load.go b/internal/declcfg/load.go
index 7438dcd3..94786cb6 100644
--- a/internal/declcfg/load.go
+++ b/internal/declcfg/load.go
@@ -6,6 +6,7 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"strings"
 
 	"github.com/operator-framework/api/pkg/operators"
 	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -79,6 +80,7 @@ func readYAMLOrJSON(r io.Reader) (*DeclarativeConfig, error) {
 		if err := dec.Decode(&doc); err != nil {
 			break
 		}
+		doc = []byte(strings.NewReplacer(`\u003c`, "<", `\u003e`, ">", `\u0026`, "&").Replace(string(doc)))
 
 		var in Meta
 		if err := json.Unmarshal(doc, &in); err != nil {
diff --git a/internal/declcfg/write.go b/internal/declcfg/write.go
index 55d54d55..5df463c8 100644
--- a/internal/declcfg/write.go
+++ b/internal/declcfg/write.go
@@ -3,138 +3,13 @@ package declcfg
 import (
 	"bytes"
 	"encoding/json"
-	"fmt"
 	"io"
-	"io/ioutil"
-	"os"
-	"path/filepath"
 	"sort"
 
 	"k8s.io/apimachinery/pkg/util/sets"
 	"sigs.k8s.io/yaml"
-
-	"github.com/operator-framework/operator-registry/internal/property"
-)
-
-const (
-	globalName = "__global"
 )
 
-func WriteDir(cfg DeclarativeConfig, configDir string) error {
-	entries, err := ioutil.ReadDir(configDir)
-	if err != nil && !os.IsNotExist(err) {
-		return err
-	}
-	if len(entries) > 0 {
-		return fmt.Errorf("config dir %q must be empty", configDir)
-	}
-
-	return writeToFS(cfg, &diskWriter{}, configDir)
-}
-
-type fsWriter interface {
-	MkdirAll(path string, mode os.FileMode) error
-	WriteFile(path string, data []byte, mode os.FileMode) error
-}
-
-var _ fsWriter = &diskWriter{}
-
-type diskWriter struct{}
-
-func (w diskWriter) MkdirAll(path string, mode os.FileMode) error {
-	return os.MkdirAll(path, mode)
-}
-
-func (w diskWriter) WriteFile(path string, data []byte, mode os.FileMode) error {
-	return ioutil.WriteFile(path, data, mode)
-}
-
-func writeToFS(cfg DeclarativeConfig, w fsWriter, rootDir string) error {
-	bundlesByPackage := map[string][]Bundle{}
-	for _, b := range cfg.Bundles {
-		bundlesByPackage[b.Package] = append(bundlesByPackage[b.Package], b)
-	}
-	othersByPackage := map[string][]Meta{}
-	for _, o := range cfg.Others {
-		pkgName := o.Package
-		if pkgName == "" {
-			pkgName = globalName
-		}
-		othersByPackage[pkgName] = append(othersByPackage[pkgName], o)
-	}
-
-	if err := w.MkdirAll(rootDir, 0777); err != nil {
-		return fmt.Errorf("mkdir %q: %v", rootDir, err)
-	}
-
-	for _, p := range cfg.Packages {
-		fcfg := DeclarativeConfig{
-			Packages: []Package{p},
-			Bundles:  bundlesByPackage[p.Name],
-			Others:   othersByPackage[p.Name],
-		}
-		pkgDir := filepath.Join(rootDir, p.Name)
-		if err := w.MkdirAll(pkgDir, 0777); err != nil {
-			return err
-		}
-		filename := filepath.Join(pkgDir, fmt.Sprintf("%s.json", p.Name))
-		if err := writeFile(fcfg, w, filename); err != nil {
-			return err
-		}
-
-		for _, b := range fcfg.Bundles {
-			if err := writeObjectFiles(b, w, pkgDir); err != nil {
-				return fmt.Errorf("write object files for bundle %q: %v", b.Name, err)
-			}
-		}
-	}
-
-	if globals, ok := othersByPackage[globalName]; ok {
-		gcfg := DeclarativeConfig{
-			Others: globals,
-		}
-		filename := filepath.Join(rootDir, fmt.Sprintf("%s.json", globalName))
-		if err := writeFile(gcfg, w, filename); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func writeObjectFiles(b Bundle, w fsWriter, baseDir string) error {
-	props, err := property.Parse(b.Properties)
-	if err != nil {
-		return fmt.Errorf("parse properties: %v", err)
-	}
-	if len(props.BundleObjects) != len(b.Objects) {
-		return fmt.Errorf("expected %d properties of type %q, found %d", len(b.Objects), property.TypeBundleObject, len(props.BundleObjects))
-	}
-	for i, p := range props.BundleObjects {
-		if p.IsRef() {
-			objPath := filepath.Join(baseDir, p.GetRef())
-			objDir := filepath.Dir(objPath)
-			if err := w.MkdirAll(objDir, 0777); err != nil {
-				return fmt.Errorf("create directory %q for bundle object ref %q: %v", objDir, p.GetRef(), err)
-			}
-			if err := w.WriteFile(objPath, []byte(b.Objects[i]), 0666); err != nil {
-				return fmt.Errorf("write bundle object for ref %q: %v", p.GetRef(), err)
-			}
-		}
-	}
-	return nil
-}
-
-func writeFile(cfg DeclarativeConfig, w fsWriter, filename string) error {
-	buf := &bytes.Buffer{}
-	if err := WriteJSON(cfg, buf); err != nil {
-		return fmt.Errorf("write to buffer for %q: %v", filename, err)
-	}
-	if err := w.WriteFile(filename, buf.Bytes(), 0666); err != nil {
-		return fmt.Errorf("write file %q: %v", filename, err)
-	}
-	return nil
-}
-
 func WriteJSON(cfg DeclarativeConfig, w io.Writer) error {
 	enc := json.NewEncoder(w)
 	enc.SetIndent("", "    ")
@@ -199,15 +74,12 @@ func writeToEncoder(cfg DeclarativeConfig, enc encoder) error {
 	othersByPackage := map[string][]Meta{}
 	for _, o := range cfg.Others {
 		pkgName := o.Package
-		if pkgName == "" {
-			pkgName = globalName
-		}
 		pkgNames.Insert(pkgName)
 		othersByPackage[pkgName] = append(othersByPackage[pkgName], o)
 	}
 
 	for _, pName := range pkgNames.List() {
-		if pName == globalName {
+		if len(pName) == 0 {
 			continue
 		}
 		pkgs := packagesByName[pName]
@@ -238,7 +110,7 @@ func writeToEncoder(cfg DeclarativeConfig, enc encoder) error {
 		}
 	}
 
-	for _, o := range othersByPackage[globalName] {
+	for _, o := range othersByPackage[""] {
 		if err := enc.Encode(o); err != nil {
 			return err
 		}
diff --git a/internal/property/property.go b/internal/property/property.go
index 4d0438dd..ef8cf230 100644
--- a/internal/property/property.go
+++ b/internal/property/property.go
@@ -5,7 +5,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"io"
 	"io/ioutil"
 	"path/filepath"
 	"reflect"
@@ -256,7 +255,7 @@ func Build(p interface{}) (*Property, error) {
 
 	return &Property{
 		Type:  typ,
-		Value: bytes.TrimSpace(d),
+		Value: d,
 	}, nil
 }
 
@@ -270,18 +269,17 @@ func MustBuild(p interface{}) Property {
 
 func jsonMarshal(p interface{}) ([]byte, error) {
 	buf := &bytes.Buffer{}
-	enc := newJSONEncoder(buf)
-	if err := enc.Encode(p); err != nil {
+	dec := json.NewEncoder(buf)
+	dec.SetEscapeHTML(false)
+	err := dec.Encode(p)
+	if err != nil {
 		return nil, err
 	}
-	return buf.Bytes(), nil
-}
-
-func newJSONEncoder(w io.Writer) json.Encoder {
-	enc := json.NewEncoder(w)
-	enc.SetEscapeHTML(false)
-	enc.SetIndent("", "")
-	return *enc
+	out := &bytes.Buffer{}
+	if err := json.Compact(out, buf.Bytes()); err != nil {
+		return nil, err
+	}
+	return out.Bytes(), nil
 }
 
 func MustBuildPackage(name, version string) Property {
diff --git a/pkg/action/add_bundle_to_configs.go b/pkg/action/add_bundle_to_configs.go
deleted file mode 100644
index 6cf1e686..00000000
--- a/pkg/action/add_bundle_to_configs.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package action
-
-import (
-	"context"
-	"fmt"
-	"io/ioutil"
-	"os"
-
-	"github.com/otiai10/copy"
-	"github.com/sirupsen/logrus"
-
-	"github.com/operator-framework/operator-registry/internal/declcfg"
-	"github.com/operator-framework/operator-registry/pkg/registry"
-)
-
-type AddConfigRequest struct {
-	ConfigsDir string
-	Bundles    []BundleExtractor
-}
-
-type BundleAdder struct {
-	Logger *logrus.Entry
-}
-
-func NewBundleAdder(logger *logrus.Entry) BundleAdder {
-	return BundleAdder{
-		Logger: logger,
-	}
-}
-
-func (b BundleAdder) AddToConfig(request AddConfigRequest) error {
-	b.Logger.Infof("loading configs from directory")
-	decCfg, err := declcfg.LoadDir(request.ConfigsDir)
-	if err != nil {
-		return fmt.Errorf("error loading directory %q: %v", request.ConfigsDir, err)
-	}
-
-	model, err := declcfg.ConvertToModel(*decCfg)
-	if err != nil {
-		return fmt.Errorf("error converting configs to internal model:%v", err)
-	}
-
-	for _, bundle := range request.Bundles {
-		b, err := bundle.ExtractBundle(context.TODO())
-		mBundles, err := registry.ConvertRegistryBundleToModelBundles(b)
-		if err != nil {
-			return fmt.Errorf("error creating internal model bundles from registry bundle %q: %v", b.BundleImage, err)
-		}
-		for _, b := range mBundles {
-			model.AddBundle(b)
-		}
-	}
-
-	newDecCfg := declcfg.ConvertFromModel(model)
-	tmpDir, err := ioutil.TempDir("./", "configs_tmp")
-	if err != nil {
-		return fmt.Errorf("error creating temp directory to write modified configs into:%v", err)
-	}
-
-	b.Logger.Infof("writing modified temp directory to %q", tmpDir)
-	err = declcfg.WriteDir(newDecCfg, tmpDir)
-
-	if err != nil {
-		return fmt.Errorf("error writing new configs into %q:%v", tmpDir, err)
-	} else {
-		if err := os.RemoveAll(request.ConfigsDir); err != nil { // this is required because WriteDir only writes to an empty directory
-			return fmt.Errorf("could not remove existing output dir %q: %v", request.ConfigsDir, err)
-		}
-		if err := os.Mkdir(request.ConfigsDir, os.ModePerm); err != nil {
-			return fmt.Errorf("could not create new directory %q: %v", request.ConfigsDir, err)
-		}
-		b.Logger.Infof("rewriting contents of existing configs directory %q with contents from %q", request.ConfigsDir, tmpDir)
-		err = copy.Copy(tmpDir, request.ConfigsDir)
-		if err != nil {
-			return fmt.Errorf("error copying new configs to %q:%v", request.ConfigsDir, err)
-		}
-	}
-	return os.RemoveAll(tmpDir)
-}
-
-func existingPackage(decCfg *declcfg.DeclarativeConfig, pkg string) bool {
-	for _, p := range decCfg.Packages {
-		if p.Name == pkg {
-			return true
-		}
-	}
-	return false
-}
diff --git a/pkg/action/bundle_extractor.go b/pkg/action/bundle_extractor.go
deleted file mode 100644
index ad289f5e..00000000
--- a/pkg/action/bundle_extractor.go
+++ /dev/null
@@ -1,59 +0,0 @@
-package action
-
-import (
-	"context"
-	"fmt"
-	"io/ioutil"
-	"os"
-
-	"github.com/operator-framework/operator-registry/pkg/image"
-	"github.com/operator-framework/operator-registry/pkg/registry"
-	"github.com/sirupsen/logrus"
-)
-
-type BundleExtractor interface {
-	ExtractBundle(ctx context.Context) (*registry.Bundle, error)
-}
-
-type imageBundleExtractor struct {
-	imgRef string
-	reg    image.Registry
-	logger *logrus.Entry
-}
-
-func NewImageBundleExtractor(ref string, reg image.Registry, logger *logrus.Entry) imageBundleExtractor {
-	return imageBundleExtractor{
-		imgRef: ref,
-		reg:    reg,
-		logger: logger,
-	}
-}
-
-func (i imageBundleExtractor) ExtractBundle(ctx context.Context) (*registry.Bundle, error) {
-
-	simpleRef := image.SimpleReference(i.imgRef)
-	tmpDir, err := ioutil.TempDir("./", "bundle_tmp")
-	if err != nil {
-		return nil, fmt.Errorf("error creating temp directory to unpack bundle image %q in:%v", simpleRef.String(), err)
-	}
-	defer func() {
-		i.logger.Infof("Removing temp directory %q bundle was unpacked in", tmpDir)
-		if err := os.RemoveAll(tmpDir); err != nil {
-			i.logger.Errorf("error removing temp directory %q bundle was unpacked in: %v", tmpDir, err)
-		}
-	}()
-	i.logger.Infof("Pulling bundle %q", simpleRef.String())
-	if err := i.reg.Pull(ctx, simpleRef); err != nil {
-		return nil, fmt.Errorf("error pulling image %q into registry:%v", simpleRef.String(), err)
-	}
-	i.logger.Infof("Unpacking bundle %q into %q", simpleRef.String(), tmpDir)
-	err = i.reg.Unpack(ctx, simpleRef, tmpDir)
-	if err != nil {
-		return nil, fmt.Errorf("error unpacking image %q: %v", simpleRef.String(), err)
-	}
-	img, err := registry.NewImageInput(simpleRef, tmpDir)
-	if err != nil {
-		return nil, fmt.Errorf("error interpreting bundle image %q: %v", simpleRef.String(), err)
-	}
-	return img.Bundle, nil
-}
diff --git a/pkg/api/api_to_model.go b/pkg/api/api_to_model.go
index 5ed1e5bc..eccbbcf0 100644
--- a/pkg/api/api_to_model.go
+++ b/pkg/api/api_to_model.go
@@ -3,11 +3,7 @@ package api
 import (
 	"encoding/json"
 	"fmt"
-	"path/filepath"
-	"strings"
-
-	"github.com/ghodss/yaml"
-	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"sort"
 
 	"github.com/operator-framework/operator-registry/internal/model"
 	"github.com/operator-framework/operator-registry/internal/property"
@@ -119,10 +115,17 @@ func convertAPIBundleToModelProperties(b *Bundle) ([]property.Property, error) {
 		out = append(out, property.MustBuildGVKRequired(p.Group, p.Version, p.Kind))
 	}
 
-	for i, obj := range b.Object {
-		out = append(out, property.MustBuildBundleObjectRef(filepath.Join("objects", b.CsvName, objectFilename(obj, i))))
+	for _, obj := range b.Object {
+		out = append(out, property.MustBuildBundleObjectData([]byte(obj)))
 	}
 
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].Type != out[j].Type {
+			return out[i].Type < out[j].Type
+		}
+		return string(out[i].Value) < string(out[j].Value)
+	})
+
 	return out, nil
 }
 
@@ -148,19 +151,3 @@ func getRelatedImages(csvJSON string) ([]model.RelatedImage, error) {
 	}
 	return relatedImages, nil
 }
-
-func objectFilename(obj string, idx int) string {
-	name, kind := fmt.Sprintf("obj%04d", idx), ""
-	u := unstructured.Unstructured{}
-	if err := yaml.Unmarshal([]byte(obj), &u); err == nil {
-		if u.GetName() != "" {
-			name = u.GetName()
-		}
-		gvk := u.GroupVersionKind()
-		kind = fmt.Sprintf("%s_%s_%s", gvk.Group, gvk.Version, strings.ToLower(gvk.Kind))
-	}
-	if kind == "" {
-		return fmt.Sprintf("%s", name)
-	}
-	return fmt.Sprintf("%s_%s.yaml", name, kind)
-}
diff --git a/pkg/image/mock.go b/pkg/image/mock.go
new file mode 100644
index 00000000..6f856c30
--- /dev/null
+++ b/pkg/image/mock.go
@@ -0,0 +1,86 @@
+package image
+
+import (
+	"context"
+	"errors"
+	"io/fs"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sync"
+)
+
+var _ Registry = &MockRegistry{}
+
+type MockRegistry struct {
+	RemoteImages map[Reference]*MockImage
+	localImages  map[Reference]*MockImage
+	m            sync.RWMutex
+}
+
+type MockImage struct {
+	Labels map[string]string
+	FS     fs.FS
+}
+
+func (i *MockImage) unpack(dir string) error {
+	return fs.WalkDir(i.FS, ".", func(path string, entry fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if entry.IsDir() {
+			return nil
+		}
+		data, err := fs.ReadFile(i.FS, path)
+		if err != nil {
+			return err
+		}
+		path = filepath.Join(dir, path)
+		pathDir := filepath.Dir(path)
+		if err := os.MkdirAll(pathDir, 0777); err != nil {
+			return err
+		}
+		return ioutil.WriteFile(path, data, 0666)
+	})
+}
+
+func (m *MockRegistry) Pull(_ context.Context, ref Reference) error {
+	image, ok := m.RemoteImages[ref]
+	if !ok {
+		return errors.New("not found")
+	}
+	m.m.Lock()
+	defer m.m.Unlock()
+	if m.localImages == nil {
+		m.localImages = map[Reference]*MockImage{}
+	}
+	m.localImages[ref] = image
+	return nil
+}
+
+func (m *MockRegistry) Unpack(_ context.Context, ref Reference, dir string) error {
+	m.m.RLock()
+	defer m.m.RUnlock()
+	image, ok := m.localImages[ref]
+	if !ok {
+		return errors.New("not found")
+	}
+	return image.unpack(dir)
+}
+
+func (m *MockRegistry) Labels(_ context.Context, ref Reference) (map[string]string, error) {
+	m.m.RLock()
+	defer m.m.RUnlock()
+	image, ok := m.localImages[ref]
+	if !ok {
+		return nil, errors.New("not found")
+	}
+	return image.Labels, nil
+}
+
+func (m *MockRegistry) Destroy() error {
+	m.m.Lock()
+	defer m.m.Unlock()
+	m.localImages = nil
+	return nil
+}
diff --git a/pkg/registry/registry_to_model.go b/pkg/registry/registry_to_model.go
index 077eb2c6..8d921a29 100644
--- a/pkg/registry/registry_to_model.go
+++ b/pkg/registry/registry_to_model.go
@@ -3,6 +3,7 @@ package registry
 import (
 	"encoding/json"
 	"fmt"
+	"sort"
 	"strings"
 
 	"github.com/operator-framework/operator-registry/internal/model"
@@ -54,7 +55,7 @@ func ConvertRegistryBundleToModelBundles(b *Bundle) ([]model.Bundle, error) {
 }
 
 func registryBundleToModelBundle(b *Bundle) (*model.Bundle, error) {
-	bundleProps, err := convertRegistryBundleToModelProperties(b)
+	bundleProps, err := PropertiesFromBundle(b)
 	if err != nil {
 		return nil, fmt.Errorf("error converting properties for internal model: %v", err)
 	}
@@ -86,87 +87,92 @@ func registryBundleToModelBundle(b *Bundle) (*model.Bundle, error) {
 	}, nil
 }
 
-func convertRegistryBundleToModelProperties(b *Bundle) ([]property.Property, error) {
-	var out []property.Property
-
-	skips, err := b.csv.GetSkips()
+func PropertiesFromBundle(b *Bundle) ([]property.Property, error) {
+	csv, err := b.ClusterServiceVersion()
 	if err != nil {
-		return nil, fmt.Errorf("Could not get Skips from CSV for bundle: %s", err)
+		return nil, fmt.Errorf("get csv: %v", err)
 	}
 
-	for _, skip := range skips {
-		out = append(out, property.MustBuildSkips(skip))
+	skips, err := csv.GetSkips()
+	if err != nil {
+		return nil, fmt.Errorf("get csv skips: %v", err)
 	}
 
-	skipRange := b.csv.GetSkipRange()
-	if skipRange != "" {
-		out = append(out, property.MustBuildSkipRange(skipRange))
+	var graphProps []property.Property
+	replaces, err := csv.GetReplaces()
+	if err != nil {
+		return nil, fmt.Errorf("get csv replaces: %v", err)
+	}
+	for _, ch := range b.Channels {
+		graphProps = append(graphProps, property.MustBuildChannel(ch, replaces))
 	}
 
-	replaces, err := b.csv.GetReplaces()
-	if err != nil {
-		return nil, fmt.Errorf("Could not get Replaces from CSV for bundle: %s", err)
+	for _, skip := range skips {
+		graphProps = append(graphProps, property.MustBuildSkips(skip))
 	}
-	for _, ch := range extractChannels(b.Annotations.Channels) {
-		out = append(out, property.MustBuildChannel(ch, replaces))
+
+	skipRange := csv.GetSkipRange()
+	if skipRange != "" {
+		graphProps = append(graphProps, property.MustBuildSkipRange(skipRange))
 	}
 
 	providedGVKs := map[property.GVK]struct{}{}
 	requiredGVKs := map[property.GVKRequired]struct{}{}
 
-	foundPackageProperty := false
+	var packageProvidedProperty *property.Property
+	var otherProps []property.Property
+
 	for i, p := range b.Properties {
 		switch p.Type {
 		case property.TypeGVK:
 			var v property.GVK
-			if err := json.Unmarshal(json.RawMessage(p.Value), &v); err != nil {
+			if err := json.Unmarshal(p.Value, &v); err != nil {
 				return nil, property.ParseError{Idx: i, Typ: p.Type, Err: err}
 			}
 			k := property.GVK{Group: v.Group, Kind: v.Kind, Version: v.Version}
 			providedGVKs[k] = struct{}{}
 		case property.TypePackage:
 			var v property.Package
-			if err := json.Unmarshal(json.RawMessage(p.Value), &v); err != nil {
+			if err := json.Unmarshal(p.Value, &v); err != nil {
 				return nil, property.ParseError{Idx: i, Typ: p.Type, Err: err}
 			}
-			out = append(out, property.MustBuildPackageRequired(v.PackageName, v.Version))
+			p := property.MustBuildPackage(v.PackageName, v.Version)
+			packageProvidedProperty = &p
 		default:
-			out = append(out, property.Property{
+			otherProps = append(otherProps, property.Property{
 				Type:  p.Type,
-				Value: json.RawMessage(p.Value),
+				Value: p.Value,
 			})
 		}
 	}
 
+	var packageRequiredProps []property.Property
 	for i, p := range b.Dependencies {
 		switch p.Type {
 		case property.TypeGVK:
 			var v property.GVK
-			if err := json.Unmarshal(json.RawMessage(p.Value), &v); err != nil {
+			if err := json.Unmarshal(p.Value, &v); err != nil {
 				return nil, property.ParseError{Idx: i, Typ: p.Type, Err: err}
 			}
 			k := property.GVKRequired{Group: v.Group, Kind: v.Kind, Version: v.Version}
 			requiredGVKs[k] = struct{}{}
 		case property.TypePackage:
-			out = append(out, property.Property{
-				Type:  property.TypePackageRequired,
-				Value: json.RawMessage(p.Value),
-			})
+			var v property.Package
+			if err := json.Unmarshal(p.Value, &v); err != nil {
+				return nil, property.ParseError{Idx: i, Typ: p.Type, Err: err}
+			}
+			packageRequiredProps = append(packageRequiredProps, property.MustBuildPackageRequired(v.PackageName, v.Version))
 		}
 	}
 
 	version, err := b.Version()
 	if err != nil {
-		return nil, fmt.Errorf("error getting bundle version from CSV %q:%v", b.csv.Name, err)
-	}
-
-	if !foundPackageProperty {
-		out = append(out, property.MustBuildPackage(b.Annotations.PackageName, version))
+		return nil, fmt.Errorf("get version: %v", err)
 	}
 
 	providedApis, err := b.ProvidedAPIs()
 	if err != nil {
-		return nil, fmt.Errorf("error getting Provided APIs for bundle %q:%v", b.Name, err)
+		return nil, fmt.Errorf("get provided apis: %v", err)
 	}
 
 	for p := range providedApis {
@@ -177,7 +183,7 @@ func convertRegistryBundleToModelProperties(b *Bundle) ([]property.Property, err
 	}
 	requiredApis, err := b.RequiredAPIs()
 	if err != nil {
-		return nil, fmt.Errorf("Could not get Required APIs from bundle:%s", err)
+		return nil, fmt.Errorf("get required apis: %v", err)
 	}
 	for p := range requiredApis {
 		k := property.GVKRequired{Group: p.Group, Kind: p.Kind, Version: p.Version}
@@ -186,6 +192,14 @@ func convertRegistryBundleToModelProperties(b *Bundle) ([]property.Property, err
 		}
 	}
 
+	var out []property.Property
+	if packageProvidedProperty == nil {
+		p := property.MustBuildPackage(b.Package, version)
+		packageProvidedProperty = &p
+	}
+	out = append(out, *packageProvidedProperty)
+	out = append(out, graphProps...)
+
 	for p := range providedGVKs {
 		out = append(out, property.MustBuildGVK(p.Group, p.Version, p.Kind))
 	}
@@ -194,6 +208,16 @@ func convertRegistryBundleToModelProperties(b *Bundle) ([]property.Property, err
 		out = append(out, property.MustBuildGVKRequired(p.Group, p.Version, p.Kind))
 	}
 
+	out = append(out, packageRequiredProps...)
+	out = append(out, otherProps...)
+
+	sort.Slice(out, func(i, j int) bool {
+		if out[i].Type != out[j].Type {
+			return out[i].Type < out[j].Type
+		}
+		return string(out[i].Value) < string(out[j].Value)
+	})
+
 	return out, nil
 }
 
diff --git a/pkg/sqlite/conversion.go b/pkg/sqlite/conversion.go
index d87c1a31..7c4fa490 100644
--- a/pkg/sqlite/conversion.go
+++ b/pkg/sqlite/conversion.go
@@ -122,9 +122,11 @@ func populatePackageIcons(ctx context.Context, pkgs model.Model, q *SQLQuerier)
 				continue
 			}
 		}
-		pkg.Icon = &model.Icon{
-			Data:      iconData,
-			MediaType: csv.Spec.Icon[0].MediaType,
+		if len(iconData) > 0 {
+			pkg.Icon = &model.Icon{
+				Data:      iconData,
+				MediaType: csv.Spec.Icon[0].MediaType,
+			}
 		}
 	}
 	return nil
diff --git a/pkg/sqlite/load.go b/pkg/sqlite/load.go
index 59bcc49d..cfb3b701 100644
--- a/pkg/sqlite/load.go
+++ b/pkg/sqlite/load.go
@@ -1457,7 +1457,7 @@ func (s *sqlLoader) DeprecateBundle(path string) error {
 
 	// Create a persistent record of the bundle's deprecation
 	// This lets us recover from losing the properties and augmented bundle rows
-	_, err = tx.Exec("INSERT INTO deprecated(operatorbundle_name) VALUES(?)", name)
+	_, err = tx.Exec("INSERT OR REPLACE INTO deprecated(operatorbundle_name) VALUES(?)", name)
 	if err != nil {
 		return err
 	}
diff --git a/pkg/sqlite/migrations/012_deprecated.go b/pkg/sqlite/migrations/012_deprecated.go
index bc9ed863..760b381f 100644
--- a/pkg/sqlite/migrations/012_deprecated.go
+++ b/pkg/sqlite/migrations/012_deprecated.go
@@ -29,7 +29,7 @@ var deprecatedMigration = &Migration{
 			return err
 		}
 
-		initDeprecated := fmt.Sprintf(`INSERT INTO deprecated(operatorbundle_name) SELECT operatorbundle_name FROM properties WHERE properties.type='%s'`, registry.DeprecatedType)
+		initDeprecated := fmt.Sprintf(`INSERT OR REPLACE INTO deprecated(operatorbundle_name) SELECT operatorbundle_name FROM properties WHERE properties.type='%s'`, registry.DeprecatedType)
 		_, err := tx.ExecContext(ctx, initDeprecated)
 
 		return err



Test patch:
diff --git a/internal/action/init_test.go b/internal/action/init_test.go
new file mode 100644
index 00000000..4b6b773a
--- /dev/null
+++ b/internal/action/init_test.go
@@ -0,0 +1,139 @@
+package action_test
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+	"testing/iotest"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/operator-framework/operator-registry/internal/action"
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+)
+
+const (
+	svgIcon = `<svg viewBox="0 0 100 100"><circle cx="25" cy="25" r="25"/></svg>`
+)
+
+func TestInit(t *testing.T) {
+	type spec struct {
+		name      string
+		init      action.Init
+		expectPkg *declcfg.Package
+		assertion require.ErrorAssertionFunc
+	}
+
+	specs := []spec{
+		{
+			name: "Success/Empty",
+			init: action.Init{},
+			expectPkg: &declcfg.Package{
+				Schema: "olm.package",
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/SetPackage",
+			init: action.Init{
+				Package: "foo",
+			},
+			expectPkg: &declcfg.Package{
+				Schema: "olm.package",
+				Name:   "foo",
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/SetDefaultChannel",
+			init: action.Init{
+				DefaultChannel: "foo",
+			},
+			expectPkg: &declcfg.Package{
+				Schema:         "olm.package",
+				DefaultChannel: "foo",
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/SetDescription",
+			init: action.Init{
+				DescriptionReader: bytes.NewBufferString("foo"),
+			},
+			expectPkg: &declcfg.Package{
+				Schema:      "olm.package",
+				Description: "foo",
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/SetIcon",
+			init: action.Init{
+				IconReader: bytes.NewBufferString(svgIcon),
+			},
+			expectPkg: &declcfg.Package{
+				Schema: "olm.package",
+				Icon: &declcfg.Icon{
+					Data:      bytes.NewBufferString(svgIcon).Bytes(),
+					MediaType: "image/svg+xml",
+				},
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/SetAll",
+			init: action.Init{
+				Package:           "a",
+				DefaultChannel:    "b",
+				DescriptionReader: bytes.NewBufferString("c"),
+				IconReader:        bytes.NewBufferString(svgIcon),
+			},
+			expectPkg: &declcfg.Package{
+				Schema:         "olm.package",
+				Name:           "a",
+				DefaultChannel: "b",
+				Description:    "c",
+				Icon: &declcfg.Icon{
+					Data:      bytes.NewBufferString(svgIcon).Bytes(),
+					MediaType: "image/svg+xml",
+				},
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Fail/ReadDescription",
+			init: action.Init{
+				DescriptionReader: iotest.ErrReader(errors.New("fail")),
+			},
+			assertion: require.Error,
+		},
+		{
+			name: "Fail/ReadIcon",
+			init: action.Init{
+				IconReader: iotest.ErrReader(errors.New("fail")),
+			},
+			assertion: require.Error,
+		},
+		{
+			name: "Fail/IconNotImage",
+			init: action.Init{
+				IconReader: bytes.NewBufferString("foo"),
+			},
+			assertion: require.Error,
+		},
+		{
+			name: "Fail/EmptyIcon",
+			init: action.Init{
+				IconReader: bytes.NewBuffer(nil),
+			},
+			assertion: require.Error,
+		},
+	}
+	for _, s := range specs {
+		t.Run(s.name, func(t *testing.T) {
+			actualPkg, actualErr := s.init.Run()
+			s.assertion(t, actualErr)
+			require.Equal(t, s.expectPkg, actualPkg)
+		})
+	}
+}
diff --git a/internal/action/render_test.go b/internal/action/render_test.go
new file mode 100644
index 00000000..60f09268
--- /dev/null
+++ b/internal/action/render_test.go
@@ -0,0 +1,291 @@
+package action_test
+
+import (
+	"context"
+	"embed"
+	"io/fs"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"k8s.io/apimachinery/pkg/util/yaml"
+
+	"github.com/operator-framework/operator-registry/internal/action"
+	"github.com/operator-framework/operator-registry/internal/declcfg"
+	"github.com/operator-framework/operator-registry/internal/property"
+	"github.com/operator-framework/operator-registry/pkg/containertools"
+	"github.com/operator-framework/operator-registry/pkg/image"
+	"github.com/operator-framework/operator-registry/pkg/lib/bundle"
+)
+
+func TestRender(t *testing.T) {
+	type spec struct {
+		name      string
+		render    action.Render
+		expectCfg *declcfg.DeclarativeConfig
+		assertion require.ErrorAssertionFunc
+	}
+
+	registry, err := newRegistry()
+	require.NoError(t, err)
+	foov1csv, err := bundleImageV1.ReadFile("testdata/foo-bundle-v0.1.0/manifests/foo.v0.1.0.csv.yaml")
+	require.NoError(t, err)
+	foov1crd, err := bundleImageV1.ReadFile("testdata/foo-bundle-v0.1.0/manifests/foos.test.foo.crd.yaml")
+	require.NoError(t, err)
+	foov2csv, err := bundleImageV2.ReadFile("testdata/foo-bundle-v0.2.0/manifests/foo.v0.2.0.csv.yaml")
+	require.NoError(t, err)
+	foov2crd, err := bundleImageV2.ReadFile("testdata/foo-bundle-v0.2.0/manifests/foos.test.foo.crd.yaml")
+	require.NoError(t, err)
+
+	foov1csv, err = yaml.ToJSON(foov1csv)
+	require.NoError(t, err)
+	foov1crd, err = yaml.ToJSON(foov1crd)
+	require.NoError(t, err)
+	foov2csv, err = yaml.ToJSON(foov2csv)
+	require.NoError(t, err)
+	foov2crd, err = yaml.ToJSON(foov2crd)
+	require.NoError(t, err)
+
+	specs := []spec{
+		{
+			name: "Success/SqliteIndexImage",
+			render: action.Render{
+				Refs:     []string{"test.registry/foo-operator/foo-index-sqlite:v0.2.0"},
+				Registry: registry,
+			},
+			expectCfg: &declcfg.DeclarativeConfig{
+				Packages: []declcfg.Package{
+					{
+						Schema:         "olm.package",
+						Name:           "foo",
+						DefaultChannel: "beta",
+					},
+				},
+				Bundles: []declcfg.Bundle{
+					{
+						Schema:  "olm.bundle",
+						Name:    "foo.v0.1.0",
+						Package: "foo",
+						Image:   "test.registry/foo-operator/foo-bundle:v0.1.0",
+						Properties: []property.Property{
+							property.MustBuildChannel("beta", ""),
+							property.MustBuildGVK("test.foo", "v1", "Foo"),
+							property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"),
+							property.MustBuildPackage("foo", "0.1.0"),
+							property.MustBuildPackageRequired("bar", "v0.1.0"),
+							property.MustBuildSkipRange("<0.1.0"),
+							property.MustBuildBundleObjectData(foov1csv),
+							property.MustBuildBundleObjectData(foov1crd),
+						},
+						RelatedImages: []declcfg.RelatedImage{
+							{
+								Name:  "operator",
+								Image: "test.registry/foo-operator/foo:v0.1.0",
+							},
+						},
+						CsvJSON: string(foov1csv),
+						Objects: []string{string(foov1csv), string(foov1crd)},
+					},
+					{
+						Schema:  "olm.bundle",
+						Name:    "foo.v0.2.0",
+						Package: "foo",
+						Image:   "test.registry/foo-operator/foo-bundle:v0.2.0",
+						Properties: []property.Property{
+							property.MustBuildChannel("beta", "foo.v0.1.0"),
+							property.MustBuildGVK("test.foo", "v1", "Foo"),
+							property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"),
+							property.MustBuildPackage("foo", "0.2.0"),
+							property.MustBuildPackageRequired("bar", "v0.1.0"),
+							property.MustBuildSkipRange("<0.2.0"),
+							property.MustBuildSkips("foo.v0.1.1"),
+							property.MustBuildSkips("foo.v0.1.2"),
+							property.MustBuildBundleObjectData(foov2csv),
+							property.MustBuildBundleObjectData(foov2crd),
+						},
+						RelatedImages: []declcfg.RelatedImage{
+							{
+								Name:  "operator",
+								Image: "test.registry/foo-operator/foo:v0.2.0",
+							},
+						},
+						CsvJSON: string(foov2csv),
+						Objects: []string{string(foov2csv), string(foov2crd)},
+					},
+				},
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/DeclcfgIndexImage",
+			render: action.Render{
+				Refs:     []string{"test.registry/foo-operator/foo-index-declcfg:v0.2.0"},
+				Registry: registry,
+			},
+			expectCfg: &declcfg.DeclarativeConfig{
+				Packages: []declcfg.Package{
+					{
+						Schema:         "olm.package",
+						Name:           "foo",
+						DefaultChannel: "beta",
+					},
+				},
+				Bundles: []declcfg.Bundle{
+					{
+						Schema:  "olm.bundle",
+						Name:    "foo.v0.1.0",
+						Package: "foo",
+						Image:   "test.registry/foo-operator/foo-bundle:v0.1.0",
+						Properties: []property.Property{
+							property.MustBuildChannel("beta", ""),
+							property.MustBuildGVK("test.foo", "v1", "Foo"),
+							property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"),
+							property.MustBuildPackage("foo", "0.1.0"),
+							property.MustBuildPackageRequired("bar", "v0.1.0"),
+							property.MustBuildSkipRange("<0.1.0"),
+							property.MustBuildBundleObjectData(foov1csv),
+							property.MustBuildBundleObjectData(foov1crd),
+						},
+						RelatedImages: []declcfg.RelatedImage{
+							{
+								Name:  "operator",
+								Image: "test.registry/foo-operator/foo:v0.1.0",
+							},
+						},
+						CsvJSON: string(foov1csv),
+						Objects: []string{string(foov1csv), string(foov1crd)},
+					},
+					{
+						Schema:  "olm.bundle",
+						Name:    "foo.v0.2.0",
+						Package: "foo",
+						Image:   "test.registry/foo-operator/foo-bundle:v0.2.0",
+						Properties: []property.Property{
+							property.MustBuildChannel("beta", "foo.v0.1.0"),
+							property.MustBuildGVK("test.foo", "v1", "Foo"),
+							property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"),
+							property.MustBuildPackage("foo", "0.2.0"),
+							property.MustBuildPackageRequired("bar", "v0.1.0"),
+							property.MustBuildSkipRange("<0.2.0"),
+							property.MustBuildSkips("foo.v0.1.1"),
+							property.MustBuildSkips("foo.v0.1.2"),
+							property.MustBuildBundleObjectData(foov2csv),
+							property.MustBuildBundleObjectData(foov2crd),
+						},
+						RelatedImages: []declcfg.RelatedImage{
+							{
+								Name:  "operator",
+								Image: "test.registry/foo-operator/foo:v0.2.0",
+							},
+						},
+						CsvJSON: string(foov2csv),
+						Objects: []string{string(foov2csv), string(foov2crd)},
+					},
+				},
+			},
+			assertion: require.NoError,
+		},
+		{
+			name: "Success/BundleImage",
+			render: action.Render{
+				Refs:     []string{"test.registry/foo-operator/foo-bundle:v0.2.0"},
+				Registry: registry,
+			},
+			expectCfg: &declcfg.DeclarativeConfig{
+				Bundles: []declcfg.Bundle{
+					{
+						Schema:  "olm.bundle",
+						Name:    "foo.v0.2.0",
+						Package: "foo",
+						Image:   "test.registry/foo-operator/foo-bundle:v0.2.0",
+						Properties: []property.Property{
+							property.MustBuildChannel("beta", "foo.v0.1.0"),
+							property.MustBuildGVK("test.foo", "v1", "Foo"),
+							property.MustBuildGVKRequired("test.bar", "v1alpha1", "Bar"),
+							property.MustBuildPackage("foo", "0.2.0"),
+							property.MustBuildPackageRequired("bar", "v0.1.0"),
+							property.MustBuildSkipRange("<0.2.0"),
+							property.MustBuildSkips("foo.v0.1.1"),
+							property.MustBuildSkips("foo.v0.1.2"),
+						},
+						RelatedImages: []declcfg.RelatedImage{
+							{
+								Name:  "operator",
+								Image: "test.registry/foo-operator/foo:v0.2.0",
+							},
+						},
+					},
+				},
+			},
+			assertion: require.NoError,
+		},
+	}
+
+	for _, s := range specs {
+		t.Run(s.name, func(t *testing.T) {
+			actualCfg, actualErr := s.render.Run(context.Background())
+			s.assertion(t, actualErr)
+			require.Equal(t, s.expectCfg, actualCfg)
+		})
+	}
+}
+
+//go:embed testdata/foo-bundle-v0.1.0/manifests/*
+//go:embed testdata/foo-bundle-v0.1.0/metadata/*
+var bundleImageV1 embed.FS
+
+//go:embed testdata/foo-bundle-v0.2.0/manifests/*
+//go:embed testdata/foo-bundle-v0.2.0/metadata/*
+var bundleImageV2 embed.FS
+
+//go:embed testdata/foo-index-v0.2.0-sqlite/database/*
+var sqliteImage embed.FS
+
+//go:embed testdata/foo-index-v0.2.0-declcfg/foo/*
+var declcfgImage embed.FS
+
+func newRegistry() (image.Registry, error) {
+	subSqliteImage, err := fs.Sub(sqliteImage, "testdata/foo-index-v0.2.0-sqlite")
+	if err != nil {
+		return nil, err
+	}
+	subDeclcfgImage, err := fs.Sub(declcfgImage, "testdata/foo-index-v0.2.0-declcfg")
+	if err != nil {
+		return nil, err
+	}
+	subBundleImageV1, err := fs.Sub(bundleImageV2, "testdata/foo-bundle-v0.1.0")
+	if err != nil {
+		return nil, err
+	}
+	subBundleImageV2, err := fs.Sub(bundleImageV2, "testdata/foo-bundle-v0.2.0")
+	if err != nil {
+		return nil, err
+	}
+	return &image.MockRegistry{
+		RemoteImages: map[image.Reference]*image.MockImage{
+			image.SimpleReference("test.registry/foo-operator/foo-index-sqlite:v0.2.0"): &image.MockImage{
+				Labels: map[string]string{
+					containertools.DbLocationLabel: "/database/index.db",
+				},
+				FS: subSqliteImage,
+			},
+			image.SimpleReference("test.registry/foo-operator/foo-index-declcfg:v0.2.0"): &image.MockImage{
+				Labels: map[string]string{
+					"operators.operatorframework.io.index.configs.v1": "/foo/index.yaml",
+				},
+				FS: subDeclcfgImage,
+			},
+			image.SimpleReference("test.registry/foo-operator/foo-bundle:v0.1.0"): &image.MockImage{
+				Labels: map[string]string{
+					bundle.PackageLabel: "foo",
+				},
+				FS: subBundleImageV1,
+			},
+			image.SimpleReference("test.registry/foo-operator/foo-bundle:v0.2.0"): &image.MockImage{
+				Labels: map[string]string{
+					bundle.PackageLabel: "foo",
+				},
+				FS: subBundleImageV2,
+			},
+		},
+	}, nil
+}
diff --git a/internal/action/testdata/foo-bundle-v0.1.0/bundle.Dockerfile b/internal/action/testdata/foo-bundle-v0.1.0/bundle.Dockerfile
new file mode 100644
index 00000000..df004392
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.1.0/bundle.Dockerfile
@@ -0,0 +1,12 @@
+FROM scratch
+
+# Core bundle labels.
+LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
+LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
+LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
+LABEL operators.operatorframework.io.bundle.package.v1=foo
+LABEL operators.operatorframework.io.bundle.channels.v1=beta
+
+# Copy files to locations specified by labels.
+COPY manifests /manifests/
+COPY metadata /metadata/
diff --git a/internal/action/testdata/foo-bundle-v0.1.0/manifests/foo.v0.1.0.csv.yaml b/internal/action/testdata/foo-bundle-v0.1.0/manifests/foo.v0.1.0.csv.yaml
new file mode 100644
index 00000000..830fb457
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.1.0/manifests/foo.v0.1.0.csv.yaml
@@ -0,0 +1,18 @@
+---
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  name: foo.v0.1.0
+  annotations:
+    olm.skipRange: <0.1.0
+spec:
+  customresourcedefinitions:
+    owned:
+      - group: test.foo
+        version: v1
+        kind: Foo
+        name: foos.test.foo
+  version: 0.1.0
+  relatedImages:
+    - name: operator
+      image: test.registry/foo-operator/foo:v0.1.0
diff --git a/internal/action/testdata/foo-bundle-v0.1.0/manifests/foos.test.foo.crd.yaml b/internal/action/testdata/foo-bundle-v0.1.0/manifests/foos.test.foo.crd.yaml
new file mode 100644
index 00000000..39b762b5
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.1.0/manifests/foos.test.foo.crd.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: foos.test.foo
+spec:
+  group: test.foo
+  names:
+    kind: Foo
+    plural: foos
+  versions:
+    - name: v1
diff --git a/internal/action/testdata/foo-bundle-v0.1.0/metadata/annotations.yaml b/internal/action/testdata/foo-bundle-v0.1.0/metadata/annotations.yaml
new file mode 100644
index 00000000..34187f12
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.1.0/metadata/annotations.yaml
@@ -0,0 +1,4 @@
+annotations:
+  operators.operatorframework.io.bundle.package.v1: foo
+  operators.operatorframework.io.bundle.channels.v1: beta
+  operators.operatorframework.io.bundle.channel.default.v1: beta
diff --git a/internal/action/testdata/foo-bundle-v0.1.0/metadata/dependencies.yaml b/internal/action/testdata/foo-bundle-v0.1.0/metadata/dependencies.yaml
new file mode 100644
index 00000000..7baa36da
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.1.0/metadata/dependencies.yaml
@@ -0,0 +1,11 @@
+---
+dependencies:
+  - type: olm.package
+    value:
+      packageName: bar
+      version: v0.1.0
+  - type: olm.gvk
+    value:
+      group: "test.bar"
+      version: "v1alpha1"
+      kind: "Bar"
diff --git a/internal/action/testdata/foo-bundle-v0.2.0/bundle.Dockerfile b/internal/action/testdata/foo-bundle-v0.2.0/bundle.Dockerfile
new file mode 100644
index 00000000..df004392
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.2.0/bundle.Dockerfile
@@ -0,0 +1,12 @@
+FROM scratch
+
+# Core bundle labels.
+LABEL operators.operatorframework.io.bundle.mediatype.v1=registry+v1
+LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/
+LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/
+LABEL operators.operatorframework.io.bundle.package.v1=foo
+LABEL operators.operatorframework.io.bundle.channels.v1=beta
+
+# Copy files to locations specified by labels.
+COPY manifests /manifests/
+COPY metadata /metadata/
diff --git a/internal/action/testdata/foo-bundle-v0.2.0/manifests/foo.v0.2.0.csv.yaml b/internal/action/testdata/foo-bundle-v0.2.0/manifests/foo.v0.2.0.csv.yaml
new file mode 100644
index 00000000..607918ec
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.2.0/manifests/foo.v0.2.0.csv.yaml
@@ -0,0 +1,22 @@
+---
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  name: foo.v0.2.0
+  annotations:
+    olm.skipRange: <0.2.0
+spec:
+  customresourcedefinitions:
+    owned:
+      - group: test.foo
+        version: v1
+        kind: Foo
+        name: foos.test.foo
+  version: 0.2.0
+  replaces: foo.v0.1.0
+  skips:
+    - foo.v0.1.1
+    - foo.v0.1.2
+  relatedImages:
+    - name: operator
+      image: test.registry/foo-operator/foo:v0.2.0
diff --git a/internal/action/testdata/foo-bundle-v0.2.0/manifests/foos.test.foo.crd.yaml b/internal/action/testdata/foo-bundle-v0.2.0/manifests/foos.test.foo.crd.yaml
new file mode 100644
index 00000000..39b762b5
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.2.0/manifests/foos.test.foo.crd.yaml
@@ -0,0 +1,12 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: foos.test.foo
+spec:
+  group: test.foo
+  names:
+    kind: Foo
+    plural: foos
+  versions:
+    - name: v1
diff --git a/internal/action/testdata/foo-bundle-v0.2.0/metadata/annotations.yaml b/internal/action/testdata/foo-bundle-v0.2.0/metadata/annotations.yaml
new file mode 100644
index 00000000..34187f12
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.2.0/metadata/annotations.yaml
@@ -0,0 +1,4 @@
+annotations:
+  operators.operatorframework.io.bundle.package.v1: foo
+  operators.operatorframework.io.bundle.channels.v1: beta
+  operators.operatorframework.io.bundle.channel.default.v1: beta
diff --git a/internal/action/testdata/foo-bundle-v0.2.0/metadata/dependencies.yaml b/internal/action/testdata/foo-bundle-v0.2.0/metadata/dependencies.yaml
new file mode 100644
index 00000000..7baa36da
--- /dev/null
+++ b/internal/action/testdata/foo-bundle-v0.2.0/metadata/dependencies.yaml
@@ -0,0 +1,11 @@
+---
+dependencies:
+  - type: olm.package
+    value:
+      packageName: bar
+      version: v0.1.0
+  - type: olm.gvk
+    value:
+      group: "test.bar"
+      version: "v1alpha1"
+      kind: "Bar"
diff --git a/internal/action/testdata/foo-index-v0.2.0-declcfg/foo/index.yaml b/internal/action/testdata/foo-index-v0.2.0-declcfg/foo/index.yaml
new file mode 100644
index 00000000..f2b9b9e4
--- /dev/null
+++ b/internal/action/testdata/foo-index-v0.2.0-declcfg/foo/index.yaml
@@ -0,0 +1,85 @@
+---
+schema: olm.package
+name: foo
+defaultChannel: beta
+---
+schema: olm.bundle
+package: foo
+name: foo.v0.1.0
+image: test.registry/foo-operator/foo-bundle:v0.1.0
+properties:
+  - type: olm.channel
+    value:
+      name: beta
+  - type: olm.gvk
+    value:
+      group: test.foo
+      kind: Foo
+      version: v1
+  - type: olm.gvk.required
+    value:
+      group: test.bar
+      kind: Bar
+      version: v1alpha1
+  - type: olm.package
+    value:
+      packageName: foo
+      version: 0.1.0
+  - type: olm.package.required
+    value:
+      packageName: bar
+      versionRange: v0.1.0
+  - type: olm.skipRange
+    value: <0.1.0
+  - type: olm.bundle.object
+    value:
+      data: eyJhcGlWZXJzaW9uIjoib3BlcmF0b3JzLmNvcmVvcy5jb20vdjFhbHBoYTEiLCJraW5kIjoiQ2x1c3RlclNlcnZpY2VWZXJzaW9uIiwibWV0YWRhdGEiOnsiYW5ub3RhdGlvbnMiOnsib2xtLnNraXBSYW5nZSI6Ilx1MDAzYzAuMS4wIn0sIm5hbWUiOiJmb28udjAuMS4wIn0sInNwZWMiOnsiY3VzdG9tcmVzb3VyY2VkZWZpbml0aW9ucyI6eyJvd25lZCI6W3siZ3JvdXAiOiJ0ZXN0LmZvbyIsImtpbmQiOiJGb28iLCJuYW1lIjoiZm9vcy50ZXN0LmZvbyIsInZlcnNpb24iOiJ2MSJ9XX0sInJlbGF0ZWRJbWFnZXMiOlt7ImltYWdlIjoidGVzdC5yZWdpc3RyeS9mb28tb3BlcmF0b3IvZm9vOnYwLjEuMCIsIm5hbWUiOiJvcGVyYXRvciJ9XSwidmVyc2lvbiI6IjAuMS4wIn19
+  - type: olm.bundle.object
+    value:
+      data: eyJhcGlWZXJzaW9uIjoiYXBpZXh0ZW5zaW9ucy5rOHMuaW8vdjEiLCJraW5kIjoiQ3VzdG9tUmVzb3VyY2VEZWZpbml0aW9uIiwibWV0YWRhdGEiOnsibmFtZSI6ImZvb3MudGVzdC5mb28ifSwic3BlYyI6eyJncm91cCI6InRlc3QuZm9vIiwibmFtZXMiOnsia2luZCI6IkZvbyIsInBsdXJhbCI6ImZvb3MifSwidmVyc2lvbnMiOlt7Im5hbWUiOiJ2MSJ9XX19
+relatedImages:
+  - image: test.registry/foo-operator/foo:v0.1.0
+    name: operator
+---
+schema: olm.bundle
+package: foo
+name: foo.v0.2.0
+image: test.registry/foo-operator/foo-bundle:v0.2.0
+properties:
+  - type: olm.channel
+    value:
+      name: beta
+      replaces: foo.v0.1.0
+  - type: olm.gvk
+    value:
+      group: test.foo
+      kind: Foo
+      version: v1
+  - type: olm.gvk.required
+    value:
+      group: test.bar
+      kind: Bar
+      version: v1alpha1
+  - type: olm.package
+    value:
+      packageName: foo
+      version: 0.2.0
+  - type: olm.package.required
+    value:
+      packageName: bar
+      versionRange: v0.1.0
+  - type: olm.skipRange
+    value: <0.2.0
+  - type: olm.skips
+    value: foo.v0.1.1
+  - type: olm.skips
+    value: foo.v0.1.2
+  - type: olm.bundle.object
+    value:
+      data: eyJhcGlWZXJzaW9uIjoib3BlcmF0b3JzLmNvcmVvcy5jb20vdjFhbHBoYTEiLCJraW5kIjoiQ2x1c3RlclNlcnZpY2VWZXJzaW9uIiwibWV0YWRhdGEiOnsiYW5ub3RhdGlvbnMiOnsib2xtLnNraXBSYW5nZSI6Ilx1MDAzYzAuMi4wIn0sIm5hbWUiOiJmb28udjAuMi4wIn0sInNwZWMiOnsiY3VzdG9tcmVzb3VyY2VkZWZpbml0aW9ucyI6eyJvd25lZCI6W3siZ3JvdXAiOiJ0ZXN0LmZvbyIsImtpbmQiOiJGb28iLCJuYW1lIjoiZm9vcy50ZXN0LmZvbyIsInZlcnNpb24iOiJ2MSJ9XX0sInJlbGF0ZWRJbWFnZXMiOlt7ImltYWdlIjoidGVzdC5yZWdpc3RyeS9mb28tb3BlcmF0b3IvZm9vOnYwLjIuMCIsIm5hbWUiOiJvcGVyYXRvciJ9XSwicmVwbGFjZXMiOiJmb28udjAuMS4wIiwic2tpcHMiOlsiZm9vLnYwLjEuMSIsImZvby52MC4xLjIiXSwidmVyc2lvbiI6IjAuMi4wIn19
+  - type: olm.bundle.object
+    value:
+      data: eyJhcGlWZXJzaW9uIjoiYXBpZXh0ZW5zaW9ucy5rOHMuaW8vdjEiLCJraW5kIjoiQ3VzdG9tUmVzb3VyY2VEZWZpbml0aW9uIiwibWV0YWRhdGEiOnsibmFtZSI6ImZvb3MudGVzdC5mb28ifSwic3BlYyI6eyJncm91cCI6InRlc3QuZm9vIiwibmFtZXMiOnsia2luZCI6IkZvbyIsInBsdXJhbCI6ImZvb3MifSwidmVyc2lvbnMiOlt7Im5hbWUiOiJ2MSJ9XX19
+relatedImages:
+  - image: test.registry/foo-operator/foo:v0.2.0
+    name: operator
diff --git a/internal/action/testdata/foo-index-v0.2.0-sqlite/database/index.db b/internal/action/testdata/foo-index-v0.2.0-sqlite/database/index.db
new file mode 100644
index 00000000..35e40341
Binary files /dev/null and b/internal/action/testdata/foo-index-v0.2.0-sqlite/database/index.db differ
diff --git a/internal/declcfg/load_test.go b/internal/declcfg/load_test.go
index e4727616..743737be 100644
--- a/internal/declcfg/load_test.go
+++ b/internal/declcfg/load_test.go
@@ -170,8 +170,12 @@ func TestLoadDir(t *testing.T) {
 							{Type: "olm.package", Value: json.RawMessage(`{"packageName":"etcd","version":"0.6.1"}`)},
 							{Type: "olm.gvk", Value: json.RawMessage(`{"group":"etcd.database.coreos.com","kind":"EtcdCluster","version":"v1beta2"}`)},
 							{Type: "olm.channel", Value: json.RawMessage(`{"name":"alpha"}`)},
+							{Type: "olm.skipRange", Value: json.RawMessage(`"<0.6.1"`)},
+							{Type: "olm.bundle.object", Value: json.RawMessage(`{"ref":"etcdoperator.v0.6.1.clusterserviceversion.yaml"}`)},
 						},
 						RelatedImages: []RelatedImage{{Name: "etcdv0.6.1", Image: "quay.io/coreos/etcd-operator@sha256:bd944a211eaf8f31da5e6d69e8541e7cada8f16a9f7a5a570b22478997819943"}},
+						Objects:       []string{string(mustLoadFile(t, "testdata/valid/etcdoperator.v0.6.1.clusterserviceversion.yaml"))},
+						CsvJSON:       string(mustLoadFile(t, "testdata/valid/etcdoperator.v0.6.1.clusterserviceversion.yaml")),
 					},
 					{
 						Schema:  "olm.bundle",
@@ -206,7 +210,7 @@ func TestLoadDir(t *testing.T) {
 						Properties: []property.Property{
 							{Type: "olm.package", Value: json.RawMessage(`{"packageName":"etcd","version":"0.9.2-clusterwide"}`)},
 							{Type: "olm.gvk", Value: json.RawMessage(`{"group":"etcd.database.coreos.com","kind":"EtcdBackup","version":"v1beta2"}`)},
-							{Type: "olm.skipRange", Value: json.RawMessage(`"\u003e=0.9.0 \u003c=0.9.1"`)},
+							{Type: "olm.skipRange", Value: json.RawMessage(`">=0.9.0 <=0.9.1"`)},
 							{Type: "olm.skips", Value: json.RawMessage(`"etcdoperator.v0.6.1"`)},
 							{Type: "olm.skips", Value: json.RawMessage(`"etcdoperator.v0.9.0"`)},
 							{Type: "olm.channel", Value: json.RawMessage(`{"name":"clusterwide-alpha","replaces":"etcdoperator.v0.9.0"}`)},
@@ -220,7 +224,7 @@ func TestLoadDir(t *testing.T) {
 						Image:   "quay.io/operatorhubio/etcd:v0.9.4",
 						Properties: []property.Property{
 							{Type: "olm.package", Value: json.RawMessage(`{"packageName":"etcd","version":"0.9.4"}`)},
-							{Type: "olm.package.required", Value: json.RawMessage(`{"packageName":"test","versionRange":"\u003e=1.2.3 \u003c2.0.0-0"}`)},
+							{Type: "olm.package.required", Value: json.RawMessage(`{"packageName":"test","versionRange":">=1.2.3 <2.0.0-0"}`)},
 							{Type: "olm.gvk", Value: json.RawMessage(`{"group":"etcd.database.coreos.com","kind":"EtcdBackup","version":"v1beta2"}`)},
 							{Type: "olm.gvk.required", Value: json.RawMessage(`{"group":"testapi.coreos.com","kind":"Testapi","version":"v1"}`)},
 							{Type: "olm.channel", Value: json.RawMessage(`{"name":"singlenamespace-alpha","replaces":"etcdoperator.v0.9.2"}`)},
@@ -261,3 +265,10 @@ func TestLoadDir(t *testing.T) {
 		})
 	}
 }
+
+func mustLoadFile(t *testing.T, path string) []byte {
+	t.Helper()
+	data, err := os.ReadFile(path)
+	require.NoError(t, err)
+	return data
+}
diff --git a/internal/declcfg/testdata/valid/etcd.yaml b/internal/declcfg/testdata/valid/etcd.yaml
index 2d8b947e..aab61493 100644
--- a/internal/declcfg/testdata/valid/etcd.yaml
+++ b/internal/declcfg/testdata/valid/etcd.yaml
@@ -25,6 +25,11 @@ properties:
   - type: olm.channel
     value:
       name: alpha
+  - type: olm.skipRange
+    value: <0.6.1
+  - type: olm.bundle.object
+    value:
+      ref: etcdoperator.v0.6.1.clusterserviceversion.yaml
 relatedImages:
   - image: quay.io/coreos/etcd-operator@sha256:bd944a211eaf8f31da5e6d69e8541e7cada8f16a9f7a5a570b22478997819943
     name: etcdv0.6.1
diff --git a/internal/declcfg/testdata/valid/etcdoperator.v0.6.1.clusterserviceversion.yaml b/internal/declcfg/testdata/valid/etcdoperator.v0.6.1.clusterserviceversion.yaml
new file mode 100644
index 00000000..f9b361f6
--- /dev/null
+++ b/internal/declcfg/testdata/valid/etcdoperator.v0.6.1.clusterserviceversion.yaml
@@ -0,0 +1,190 @@
+apiVersion: operators.coreos.com/v1alpha1
+kind: ClusterServiceVersion
+metadata:
+  annotations:
+    capabilities: Full Lifecycle
+    description: etcd is a distributed key value store providing a reliable way to
+      store data across a cluster of machines.
+    tectonic-visibility: ocs
+  name: etcdoperator.v0.6.1
+  namespace: placeholder
+spec:
+  customresourcedefinitions:
+    owned:
+    - description: Represents a cluster of etcd nodes.
+      displayName: etcd Cluster
+      kind: EtcdCluster
+      name: etcdclusters.etcd.database.coreos.com
+      resources:
+      - kind: Service
+        version: v1
+      - kind: Pod
+        version: v1
+      specDescriptors:
+      - description: The desired number of member Pods for the etcd cluster.
+        displayName: Size
+        path: size
+        x-descriptors:
+        - urn:alm:descriptor:com.tectonic.ui:podCount
+      statusDescriptors:
+      - description: The status of each of the member Pods for the etcd cluster.
+        displayName: Member Status
+        path: members
+        x-descriptors:
+        - urn:alm:descriptor:com.tectonic.ui:podStatuses
+      - description: The service at which the running etcd cluster can be accessed.
+        displayName: Service
+        path: service
+        x-descriptors:
+        - urn:alm:descriptor:io.kubernetes:Service
+      - description: The current size of the etcd cluster.
+        displayName: Cluster Size
+        path: size
+      - description: The current version of the etcd cluster.
+        displayName: Current Version
+        path: currentVersion
+      - description: The target version of the etcd cluster, after upgrading.
+        displayName: Target Version
+        path: targetVersion
+      - description: The current status of the etcd cluster.
+        displayName: Status
+        path: phase
+        x-descriptors:
+        - urn:alm:descriptor:io.kubernetes.phase
+      - description: Explanation for the current status of the cluster.
+        displayName: Status Details
+        path: reason
+        x-descriptors:
+        - urn:alm:descriptor:io.kubernetes.phase:reason
+      version: v1beta2
+  description: "etcd is a distributed key value store that provides a reliable way\
+    \ to store data across a cluster of machines. It\xE2\u20AC\u2122s open-source\
+    \ and available on GitHub. etcd gracefully handles leader elections during network\
+    \ partitions and will tolerate machine failure, including the leader. Your applications\
+    \ can read and write data into etcd.\nA simple use-case is to store database connection\
+    \ details or feature flags within etcd as key value pairs. These values can be\
+    \ watched, allowing your app to reconfigure itself when they change. Advanced\
+    \ uses take advantage of the consistency guarantees to implement database leader\
+    \ elections or do distributed locking across a cluster of workers.\n\n_The etcd\
+    \ Open Cloud Service is Public Alpha. The goal before Beta is to fully implement\
+    \ backup features._\n\n### Reading and writing to etcd\n\nCommunicate with etcd\
+    \ though its command line utility `etcdctl` or with the API using the automatically\
+    \ generated Kubernetes Service.\n\n[Read the complete guide to using the etcd\
+    \ Open Cloud Service](https://coreos.com/tectonic/docs/latest/alm/etcd-ocs.html)\n\
+    \n### Supported Features\n**High availability**\nMultiple instances of etcd are\
+    \ networked together and secured. Individual failures or networking issues are\
+    \ transparently handled to keep your cluster up and running.\n**Automated updates**\n\
+    Rolling out a new etcd version works like all Kubernetes rolling updates. Simply\
+    \ declare the desired version, and the etcd service starts a safe rolling update\
+    \ to the new version automatically.\n**Backups included**\nComing soon, the ability\
+    \ to schedule backups to happen on or off cluster.\n"
+  displayName: etcd
+  icon:
+  - base64data: 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
+    mediatype: image/png
+  install:
+    spec:
+      deployments:
+      - name: etcd-operator
+        spec:
+          replicas: 1
+          selector:
+            matchLabels:
+              name: etcd-operator-alm-owned
+          template:
+            metadata:
+              labels:
+                name: etcd-operator-alm-owned
+              name: etcd-operator-alm-owned
+            spec:
+              containers:
+              - command:
+                - etcd-operator
+                - --create-crd=false
+                env:
+                - name: MY_POD_NAMESPACE
+                  valueFrom:
+                    fieldRef:
+                      fieldPath: metadata.namespace
+                - name: MY_POD_NAME
+                  valueFrom:
+                    fieldRef:
+                      fieldPath: metadata.name
+                image: quay.io/coreos/etcd-operator@sha256:bd944a211eaf8f31da5e6d69e8541e7cada8f16a9f7a5a570b22478997819943
+                name: etcd-operator
+              serviceAccountName: etcd-operator
+      permissions:
+      - rules:
+        - apiGroups:
+          - etcd.database.coreos.com
+          resources:
+          - etcdclusters
+          verbs:
+          - '*'
+        - apiGroups:
+          - storage.k8s.io
+          resources:
+          - storageclasses
+          verbs:
+          - '*'
+        - apiGroups:
+          - ''
+          resources:
+          - pods
+          - services
+          - endpoints
+          - persistentvolumeclaims
+          - events
+          verbs:
+          - '*'
+        - apiGroups:
+          - apps
+          resources:
+          - deployments
+          verbs:
+          - '*'
+        - apiGroups:
+          - ''
+          resources:
+          - secrets
+          verbs:
+          - get
+        serviceAccountName: etcd-operator
+    strategy: deployment
+  installModes:
+  - supported: true
+    type: OwnNamespace
+  - supported: true
+    type: SingleNamespace
+  - supported: false
+    type: MultiNamespace
+  - supported: true
+    type: AllNamespaces
+  keywords:
+  - etcd
+  - key value
+  - database
+  - coreos
+  - open source
+  labels:
+    alm-owner-etcd: etcdoperator
+    alm-status-descriptors: etcdoperator.v0.6.1
+    operated-by: etcdoperator
+  links:
+  - name: Blog
+    url: https://coreos.com/etcd
+  - name: Documentation
+    url: https://coreos.com/operators/etcd/docs/latest/
+  - name: etcd Operator Source Code
+    url: https://github.com/coreos/etcd-operator
+  maintainers:
+  - email: support@coreos.com
+    name: CoreOS, Inc
+  maturity: alpha
+  provider:
+    name: CoreOS, Inc
+  selector:
+    matchLabels:
+      alm-owner-etcd: etcdoperator
+      operated-by: etcdoperator
+  version: 0.6.1
diff --git a/internal/declcfg/write_test.go b/internal/declcfg/write_test.go
index c5479361..e48c139e 100644
--- a/internal/declcfg/write_test.go
+++ b/internal/declcfg/write_test.go
@@ -3,170 +3,11 @@ package declcfg
 import (
 	"bytes"
 	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"os"
-	"path/filepath"
 	"testing"
 
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	"k8s.io/apimachinery/pkg/util/rand"
 )
 
-func TestWriteDir(t *testing.T) {
-	type spec struct {
-		name      string
-		cfg       DeclarativeConfig
-		setupDir  func() (string, error)
-		assertion require.ErrorAssertionFunc
-	}
-	setupNonExistentDir := func() (string, error) {
-		return filepath.Join(os.TempDir(), "decl-write-dir-"+rand.String(5)), nil
-	}
-	setupEmptyDir := func() (string, error) { return ioutil.TempDir("", "decl-write-dir-") }
-	setupNonEmptyDir := func() (string, error) {
-		dir, err := ioutil.TempDir("", "decl-write-dir-")
-		if err != nil {
-			return "", err
-		}
-		if _, err := ioutil.TempFile(dir, "decl-write-dir-file-"); err != nil {
-			return "", err
-		}
-		return dir, nil
-	}
-	setupFile := func() (string, error) {
-		f, err := ioutil.TempFile("", "decl-write-dir-file-")
-		if err != nil {
-			return "", err
-		}
-		return f.Name(), nil
-	}
-
-	specs := []spec{
-		{
-			name:      "Success/NonExistentDir",
-			cfg:       buildValidDeclarativeConfig(true),
-			setupDir:  setupNonExistentDir,
-			assertion: require.NoError,
-		},
-		{
-			name:      "Success/EmptyDir",
-			cfg:       buildValidDeclarativeConfig(true),
-			setupDir:  setupEmptyDir,
-			assertion: require.NoError,
-		},
-		{
-			name:      "Error/NotADir",
-			cfg:       buildValidDeclarativeConfig(true),
-			setupDir:  setupFile,
-			assertion: require.Error,
-		},
-		{
-			name:      "Error/NonEmptyDir",
-			cfg:       buildValidDeclarativeConfig(true),
-			setupDir:  setupNonEmptyDir,
-			assertion: require.Error,
-		},
-	}
-
-	for _, s := range specs {
-		t.Run(s.name, func(t *testing.T) {
-			testDir, err := s.setupDir()
-			require.NoError(t, err)
-			defer func() {
-				require.NoError(t, os.RemoveAll(testDir))
-			}()
-
-			err = WriteDir(s.cfg, testDir)
-			s.assertion(t, err)
-			if err == nil {
-				entries, err := ioutil.ReadDir(testDir)
-				require.NoError(t, err)
-				entryNames := []string{}
-				for _, f := range entries {
-					entryNames = append(entryNames, f.Name())
-				}
-
-				expectedEntryNames := []string{
-					fmt.Sprintf("%s.json", globalName),
-					"anakin",
-					"boba-fett",
-				}
-				require.ElementsMatch(t, expectedEntryNames, entryNames)
-
-				anakinFilename := filepath.Join(testDir, "anakin", "anakin.json")
-				anakinFile, err := os.Open(anakinFilename)
-				require.NoError(t, err)
-				defer anakinFile.Close()
-				anakin, err := readYAMLOrJSON(anakinFile)
-				require.NoError(t, err)
-				assert.Len(t, anakin.Packages, 1)
-				assert.Len(t, anakin.Bundles, 3)
-				assert.Len(t, anakin.Others, 1)
-
-				bobaFettFilename := filepath.Join(testDir, "boba-fett", "boba-fett.json")
-				bobaFettFile, err := os.Open(bobaFettFilename)
-				require.NoError(t, err)
-				defer bobaFettFile.Close()
-				bobaFett, err := readYAMLOrJSON(bobaFettFile)
-				require.NoError(t, err)
-				assert.Len(t, bobaFett.Packages, 1)
-				assert.Len(t, bobaFett.Bundles, 2)
-				assert.Len(t, bobaFett.Others, 1)
-
-				globalFilename := filepath.Join(testDir, fmt.Sprintf("%s.json", globalName))
-				globalFile, err := os.Open(globalFilename)
-				require.NoError(t, err)
-				globals, err := readYAMLOrJSON(globalFile)
-				require.NoError(t, err)
-				assert.Len(t, globals.Packages, 0)
-				assert.Len(t, globals.Bundles, 0)
-				assert.Len(t, globals.Others, 2)
-
-				all, err := LoadDir(testDir)
-				require.NoError(t, err)
-
-				assert.Len(t, all.Packages, 2)
-				assert.Len(t, all.Bundles, 5)
-				assert.Len(t, all.Others, 4)
-			}
-		})
-	}
-}
-
-func TestWriteLoadRoundtrip(t *testing.T) {
-	type spec struct {
-		name  string
-		write func(DeclarativeConfig, string) error
-		load  func(string) (*DeclarativeConfig, error)
-	}
-
-	specs := []spec{
-		{
-			name:  "Dir",
-			write: WriteDir,
-			load:  LoadDir,
-		},
-	}
-	for _, s := range specs {
-		t.Run(s.name, func(t *testing.T) {
-			to := buildValidDeclarativeConfig(true)
-
-			filename := filepath.Join(os.TempDir(), "declcfg-"+rand.String(5))
-			defer func() {
-				require.NoError(t, os.RemoveAll(filename))
-			}()
-			require.NoError(t, s.write(to, filename))
-
-			from, err := s.load(filename)
-			require.NoError(t, err)
-
-			equalsDeclarativeConfig(t, to, *from)
-		})
-	}
-}
-
 func TestWriteJSON(t *testing.T) {
 	type spec struct {
 		name     string
diff --git a/internal/model/model_test.go b/internal/model/model_test.go
index 1600376e..272f56ca 100644
--- a/internal/model/model_test.go
+++ b/internal/model/model_test.go
@@ -54,9 +54,12 @@ func TestNormalize(t *testing.T) {
 		assert.Equal(t, unchanged, b.Properties[0].Value)
 	})
 
-	t.Run("Success/RemoveSpacesAndHTMLEscapes", func(t *testing.T) {
-		withWhitespace := json.RawMessage("{\n\"test\":\"\u003C\"   }")
-		expected := json.RawMessage(`{"test":"<"}`)
+	t.Run("Success/RemoveSpaces", func(t *testing.T) {
+		withWhitespace := json.RawMessage(`  {  
+  "foo": "bar"   
+  
+  }  `)
+		expected := json.RawMessage(`{"foo":"bar"}`)
 		b.Properties = []property.Property{{Value: withWhitespace}}
 		pkgs.Normalize()
 		assert.Equal(t, expected, b.Properties[0].Value)
diff --git a/pkg/api/conversion_test.go b/pkg/api/conversion_test.go
index 63faa17e..badfabca 100644
--- a/pkg/api/conversion_test.go
+++ b/pkg/api/conversion_test.go
@@ -54,10 +54,10 @@ func testModelBundle() model.Bundle {
 			property.MustBuildPackageRequired("test", ">=1.2.3 <2.0.0-0"),
 			property.MustBuildGVKRequired("testapi.coreos.com", "v1", "Testapi"),
 			property.MustBuildGVK("etcd.database.coreos.com", "v1beta2", "EtcdBackup"),
-			property.MustBuildBundleObjectRef("objects/etcdoperator.v0.9.4/etcdbackups.etcd.database.coreos.com_apiextensions.k8s.io_v1beta1_customresourcedefinition.yaml"),
-			property.MustBuildBundleObjectRef("objects/etcdoperator.v0.9.4/etcdclusters.etcd.database.coreos.com_apiextensions.k8s.io_v1beta1_customresourcedefinition.yaml"),
-			property.MustBuildBundleObjectRef("objects/etcdoperator.v0.9.4/etcdoperator.v0.9.4_operators.coreos.com_v1alpha1_clusterserviceversion.yaml"),
-			property.MustBuildBundleObjectRef("objects/etcdoperator.v0.9.4/etcdrestores.etcd.database.coreos.com_apiextensions.k8s.io_v1beta1_customresourcedefinition.yaml"),
+			property.MustBuildBundleObjectData([]byte(crdbackups)),
+			property.MustBuildBundleObjectData([]byte(crdclusters)),
+			property.MustBuildBundleObjectData([]byte(csvJson)),
+			property.MustBuildBundleObjectData([]byte(crdrestores)),
 		},
 		CsvJSON: csvJson,
 		Objects: []string{
diff --git a/pkg/image/mock_test.go b/pkg/image/mock_test.go
new file mode 100644
index 00000000..6846e57a
--- /dev/null
+++ b/pkg/image/mock_test.go
@@ -0,0 +1,75 @@
+package image
+
+import (
+	"context"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"testing"
+	"testing/fstest"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestMockRegistry(t *testing.T) {
+	exists := SimpleReference("exists")
+	dne := SimpleReference("dne")
+	ctx := context.Background()
+
+	tmpDir, err := ioutil.TempDir("", "reg-test-mock-")
+	require.NoError(t, err)
+	defer os.RemoveAll(tmpDir)
+
+	r := MockRegistry{
+		RemoteImages: map[Reference]*MockImage{
+			exists: &MockImage{
+				Labels: map[string]string{
+					"key1": "value1",
+					"key2": "value2",
+				},
+				FS: fstest.MapFS{
+					"file1": &fstest.MapFile{
+						Data: []byte("data1"),
+					},
+					"subdir/file2": &fstest.MapFile{
+						Data: []byte("data2"),
+					},
+				},
+			},
+		},
+	}
+
+	// Test pull of non-existent ref
+	require.Error(t, r.Pull(ctx, dne))
+
+	// Test unpack and labels of unpulled ref
+	require.Error(t, r.Unpack(ctx, exists, tmpDir))
+	_, err = r.Labels(ctx, exists)
+	require.Error(t, err)
+
+	// Test pull of existing ref
+	require.NoError(t, r.Pull(ctx, exists))
+
+	// Test unpack and labels of existing ref
+	require.NoError(t, r.Unpack(ctx, exists, tmpDir))
+	checkFile(t, filepath.Join(tmpDir, "file1"))
+	checkFile(t, filepath.Join(tmpDir, "subdir", "file2"))
+
+	labels, err := r.Labels(ctx, exists)
+	require.NoError(t, err)
+	require.Equal(t, map[string]string{"key1": "value1", "key2": "value2"}, labels)
+
+	// Test destroy
+	require.NoError(t, r.Destroy())
+
+	// Test unpack and labels of unpulled ref
+	require.Error(t, r.Unpack(ctx, exists, tmpDir))
+	_, err = r.Labels(ctx, exists)
+	require.Error(t, err)
+}
+
+func checkFile(t *testing.T, path string) {
+	t.Helper()
+	_, err := os.Stat(path)
+	require.NoError(t, err)
+}
diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go
index 5aa173ab..c257cc1f 100644
--- a/pkg/server/server_test.go
+++ b/pkg/server/server_test.go
@@ -641,6 +641,7 @@ func testListBundles(addr string, etcdAlpha *api.Bundle, etcdStable *api.Bundle)
 }
 
 func EqualBundles(t *testing.T, expected, actual api.Bundle) {
+	t.Helper()
 	stripPlural(actual.ProvidedApis)
 	stripPlural(actual.RequiredApis)
 
diff --git a/pkg/sqlite/migrations/012_deprecated_test.go b/pkg/sqlite/migrations/012_deprecated_test.go
index 4c00a598..06e3b4ca 100644
--- a/pkg/sqlite/migrations/012_deprecated_test.go
+++ b/pkg/sqlite/migrations/012_deprecated_test.go
@@ -26,6 +26,9 @@ func TestDeprecated(t *testing.T) {
 	require.NoError(t, err)
 	_, err = db.Exec(insertProperty, registry.DeprecatedType, "operator.v1.0.0", "1.0.0", "quay.io/operator:v1.0.0")
 	require.NoError(t, err)
+	// Add a duplicate deprecated property to ensure idempotency of the migration
+	_, err = db.Exec(insertProperty, registry.DeprecatedType, "operator.v1.0.0", "1.0.0", "quay.io/operator:v1.0.0")
+	require.NoError(t, err)
 	_, err = db.Exec(insertProperty, "extraneous", "operator.v1.0.0", "1.0.0", "quay.io/operator:v1.0.0")
 	require.NoError(t, err)
 
diff --git a/test/e2e/opm_configs_test.go b/test/e2e/opm_configs_test.go
deleted file mode 100644
index c1e42125..00000000
--- a/test/e2e/opm_configs_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package e2e_test
-
-import (
-	"io/ioutil"
-	"os"
-	"sort"
-	"strings"
-
-	. "github.com/onsi/ginkgo"
-	. "github.com/onsi/gomega"
-	"github.com/operator-framework/operator-registry/internal/declcfg"
-	"github.com/operator-framework/operator-registry/pkg/action"
-	"github.com/operator-framework/operator-registry/pkg/image/containerdregistry"
-	"github.com/operator-framework/operator-registry/pkg/lib/bundle"
-	"github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"k8s.io/apimachinery/pkg/util/rand"
-)
-
-var (
-	pkg       = "prometheus"
-	chs       = "alpha,stable"
-	defaultCh = "alpha"
-
-	path1         = "manifests/prometheus/0.14.0"
-	img           = dockerHost + "/olmtest/e2e-bundle"
-	tag1          = rand.String(6)
-	containerTool = "docker"
-)
-var _ = Describe("opm", func() {
-	var (
-		configsDir string
-		reg        *containerdregistry.Registry
-	)
-	BeforeEach(func() {
-		tmpDir, err := ioutil.TempDir("", "configs")
-		Expect(err).ToNot(HaveOccurred())
-
-		configsDir = tmpDir
-		bundles := bundleLocations{
-			{img + ":" + tag1, path1},
-		}
-		for _, b := range bundles {
-			err = inTemporaryBuildContext(func() error {
-				return bundle.BuildFunc(b.path, "", b.image, containerTool, pkg, chs, defaultCh, false)
-			})
-			Expect(err).NotTo(HaveOccurred())
-		}
-		for _, b := range bundles {
-			Expect(pushWith(containerTool, b.image)).NotTo(HaveOccurred())
-		}
-
-		reg, err = containerdregistry.NewRegistry()
-		Expect(err).ToNot(HaveOccurred())
-	})
-	AfterEach(func() {
-		err := os.RemoveAll(configsDir)
-		Expect(err).ToNot(HaveOccurred())
-
-		err = reg.Destroy()
-		Expect(err).ToNot(HaveOccurred())
-	})
-	It("creates configs for bundles and adds them to a configs directory", func() {
-		When("a new bundle is added that does not belong to an existing package is added", func() {
-
-			request := action.AddConfigRequest{
-				ConfigsDir: configsDir,
-				Bundles:    []action.BundleExtractor{action.NewImageBundleExtractor(img+":"+tag1, reg, logrus.NewEntry(logrus.New()))},
-			}
-
-			adder := action.NewBundleAdder(logrus.NewEntry(logrus.New()))
-			adder.AddToConfig(request)
-
-			expectedConfigs, err := declcfg.LoadDir("testdata/test-configs")
-			Expect(err).ToNot(HaveOccurred())
-
-			actualConfigs, err := declcfg.LoadDir(configsDir)
-			Expect(err).ToNot(HaveOccurred())
-
-			equalsDeclarativeConfig(expectedConfigs, actualConfigs)
-			Expect(err).ToNot(HaveOccurred())
-
-			Expect(err).ToNot(HaveOccurred())
-		})
-	})
-
-})
-
-func equalsDeclarativeConfig(expected, actual *declcfg.DeclarativeConfig) {
-	removeJSONWhitespace(expected)
-	removeJSONWhitespace(actual)
-
-	assert.ElementsMatch(GinkgoT(), expected.Packages, actual.Packages)
-	assert.ElementsMatch(GinkgoT(), expected.Others, actual.Others)
-	require.Equal(GinkgoT(), len(expected.Bundles), len(actual.Bundles))
-	sort.SliceStable(expected.Bundles, func(i, j int) bool {
-		return expected.Bundles[i].Name < expected.Bundles[j].Name
-	})
-	sort.SliceStable(actual.Bundles, func(i, j int) bool {
-		return actual.Bundles[i].Name < actual.Bundles[j].Name
-	})
-	for i := range expected.Bundles {
-		assert.ElementsMatch(GinkgoT(), expected.Bundles[i].Properties, actual.Bundles[i].Properties)
-		expected.Bundles[i].Properties, actual.Bundles[i].Properties = nil, nil
-		expected.Bundles[i].Image, actual.Bundles[i].Image = "", ""
-		assert.Equal(GinkgoT(), expected.Bundles[i], actual.Bundles[i])
-	}
-	// In case new fields are added to the DeclarativeConfig struct in the future,
-	// test that the rest is Equal.
-	expected.Packages, actual.Packages = nil, nil
-	expected.Bundles, actual.Bundles = nil, nil
-	expected.Others, actual.Others = nil, nil
-	assert.Equal(GinkgoT(), expected, actual)
-}
-
-func removeJSONWhitespace(cfg *declcfg.DeclarativeConfig) {
-	replacer := strings.NewReplacer(" ", "", "\n", "")
-	for ib := range cfg.Bundles {
-		for ip := range cfg.Bundles[ib].Properties {
-			cfg.Bundles[ib].Properties[ip].Value = []byte(replacer.Replace(string(cfg.Bundles[ib].Properties[ip].Value)))
-		}
-	}
-	for io := range cfg.Others {
-		for _, v := range cfg.Others[io].Blob {
-			cfg.Others[io].Blob = []byte(replacer.Replace(string(v)))
-		}
-	}
-}