Repository: osbuild/osbuild-composer
Pull request #2799 (2022-07-04 23:05:35)

manifest/os: minor refactoring to make more arguments optional
All arguments that has reasonable defaults and where querying the package set chain as well as serialisation works without
the defaults being overridden, should be made public fields rather than arguments to the constructor.

It is the caller's responsibility not to change the parameters between querying the package set chains and serialising the
manifest, but otherwise they are free to reuse the pipeline objects and modify them freely.


Patch:
diff --git a/Schutzfile b/Schutzfile
index e05cd8b85..a9a9e76b5 100644
--- a/Schutzfile
+++ b/Schutzfile
@@ -153,20 +153,6 @@
       }
     ]
   },
-  "rhel-8.4": {
-    "dependencies": {
-      "osbuild": {
-        "commit": "86123da599efa117c0a8c079212becb54b304724"
-      }
-    }
-  },
-  "rhel-8.5": {
-    "dependencies": {
-      "osbuild": {
-        "commit": "86123da599efa117c0a8c079212becb54b304724"
-      }
-    }
-  },
   "rhel-8.6": {
     "dependencies": {
       "osbuild": {
diff --git a/cmd/osbuild-playground/playground.go b/cmd/osbuild-playground/playground.go
index b9acbc024..97579d54f 100644
--- a/cmd/osbuild-playground/playground.go
+++ b/cmd/osbuild-playground/playground.go
@@ -31,7 +31,7 @@ func MyManifest(m *manifest.Manifest, options *MyOptions, repos []rpmmd.RepoConf
 	build := manifest.NewBuildPipeline(m, runner, repos)
 
 	// create a non-bootable OS tree containing the `core` comps group
-	os := manifest.NewOSPipeline(m, build, false, "", "", repos, nil, manifest.BOOTLOADER_GRUB, "", "")
+	os := manifest.NewOSPipeline(m, build, manifest.ARCH_X86_64, repos)
 	os.ExtraBasePackages = []string{
 		"@core",
 	}
diff --git a/containers/osbuild-composer/entrypoint.py b/containers/osbuild-composer/entrypoint.py
index 1cf7d8622..33911c67a 100644
--- a/containers/osbuild-composer/entrypoint.py
+++ b/containers/osbuild-composer/entrypoint.py
@@ -14,6 +14,7 @@ import signal
 import socket
 import subprocess
 import sys
+import time
 
 
 class Cli(contextlib.AbstractContextManager):
@@ -34,6 +35,14 @@ class Cli(contextlib.AbstractContextManager):
             prog="container/osbuild-composer",
         )
 
+        self._parser.add_argument(
+            "--shutdown-wait-period",
+            type=int,
+            default=0,
+            dest="shutdown_wait_period",
+            help="Wait period in seconds before terminating child processes",
+        )
+
         # --[no-]composer-api
         self._parser.add_argument(
             "--composer-api",
@@ -118,34 +127,12 @@ class Cli(contextlib.AbstractContextManager):
             help="Disable the weldr-API",
         )
 
-        # --[no-]dnf-json
-        self._parser.add_argument(
-            "--dnf-json",
-            action="store_true",
-            dest="dnf_json",
-            help="Enable  dnf-json",
-        )
-        self._parser.add_argument(
-            "--no-dnf-json",
-            action="store_false",
-            dest="dnf_json",
-            help="Disable dnf-json",
-        )
-        self._parser.add_argument(
-            "--dnf-json-port",
-            type=int,
-            default=0,
-            dest="dnf_json_port",
-            help="Specify the port dnf-json should listen on",
-        )
-
         self._parser.set_defaults(
             builtin_worker=False,
             composer_api=False,
             local_worker_api=False,
             remote_worker_api=False,
             weldr_api=False,
-            dnf_json=False,
         )
 
         return self._parser.parse_args(self._argv[1:])
@@ -283,51 +270,19 @@ class Cli(contextlib.AbstractContextManager):
             preexec_fn=preexec_setenv,
         )
 
-    def _spawn_dnf_json(self):
-        cmd = [
-            "/usr/libexec/osbuild-composer/dnf-json",
-        ]
-
-        if self.args.dnf_json_port:
-            sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
-            self._exitstack.enter_context(contextlib.closing(sock))
-            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-            sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
-            sock.bind(("::", self.args.dnf_json_port))
-            sock.listen()
-        else:
-            sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-            self._exitstack.enter_context(contextlib.closing(sock))
-            os.makedirs("/run/osbuild-dnf-json/", exist_ok=True)
-            sock.bind("/run/osbuild-dnf-json/api.sock")
-            sock.listen()
-
-        dnfenv = os.environ.copy()
-        dnfenv["LISTEN_FDS"] = "1"
-        dnfenv["LISTEN_FD"] = str(sock.fileno())
-
-        return subprocess.Popen(
-            cmd,
-            cwd="/usr/libexec/osbuild-composer",
-            stdin=subprocess.DEVNULL,
-            stderr=subprocess.STDOUT,
-            env=dnfenv,
-            pass_fds=[sock.fileno()]
-        )
-
     def run(self):
         """Program Runtime"""
 
         proc_composer = None
         proc_worker = None
-        proc_dnf_json = None
         res = 0
         sockets = self._prepare_sockets()
 
         def handler(signum, frame):
+            if self.args.shutdown_wait_period:
+                time.sleep(self.args.shutdown_wait_period)
             proc_composer.terminate()
             proc_worker.terminate()
-            proc_dnf_json.terminate()
 
         signal.signal(signal.SIGTERM, handler)
 
@@ -339,9 +294,6 @@ class Cli(contextlib.AbstractContextManager):
             if self.args.builtin_worker:
                 proc_worker = self._spawn_worker()
 
-            if self.args.dnf_json:
-                proc_dnf_json = self._spawn_dnf_json()
-
             if any([self.args.weldr_api, self.args.composer_api, self.args.local_worker_api, self.args.remote_worker_api]):
                 proc_composer = self._spawn_composer(sockets)
 
@@ -353,11 +305,6 @@ class Cli(contextlib.AbstractContextManager):
                     proc_worker.terminate()
                 proc_worker.wait()
 
-            if proc_dnf_json:
-                if proc_composer:
-                    proc_dnf_json.terminate()
-                proc_dnf_json.wait()
-
         except KeyboardInterrupt:
             if proc_composer:
                 proc_composer.terminate()
@@ -365,14 +312,9 @@ class Cli(contextlib.AbstractContextManager):
             if proc_worker:
                 proc_worker.terminate()
                 proc_worker.wait()
-            if proc_dnf_json:
-                proc_dnf_json.terminate()
-                proc_dnf_json.wait()
         except:
             if proc_worker:
                 proc_worker.kill()
-            if proc_dnf_json:
-                proc_dnf_json.kill()
             if proc_composer:
                 proc_composer.kill()
             raise
diff --git a/distribution/Dockerfile-ubi b/distribution/Dockerfile-ubi
index d67823a1c..18275105c 100644
--- a/distribution/Dockerfile-ubi
+++ b/distribution/Dockerfile-ubi
@@ -23,4 +23,4 @@ COPY ./pkg/jobqueue/dbjobqueue/schemas /opt/migrate/schemas
 COPY --from=builder2 /opt/app-root/src/go/bin/tern /opt/migrate/
 
 EXPOSE 8080 8700
-ENTRYPOINT ["python3", "/opt/entrypoint.py", "--remote-worker-api", "--composer-api"]
+ENTRYPOINT ["python3", "/opt/entrypoint.py", "--remote-worker-api", "--composer-api", "--shutdown-wait-period", "5"]
diff --git a/internal/distro/fedora/distro.go b/internal/distro/fedora/distro.go
index 25ce1a9c6..6ea24f86c 100644
--- a/internal/distro/fedora/distro.go
+++ b/internal/distro/fedora/distro.go
@@ -81,9 +81,9 @@ var (
 			EnabledServices: iotServices,
 		},
 		rpmOstree:        true,
-		pipelines:        iotCommitPipelines,
+		manifest:         iotCommitManifest,
 		buildPipelines:   []string{"build"},
-		payloadPipelines: []string{"ostree-tree", "ostree-commit", "commit-archive"},
+		payloadPipelines: []string{"os", "ostree-commit", "commit-archive"},
 		exports:          []string{"commit-archive"},
 	}
 
@@ -108,7 +108,7 @@ var (
 		},
 		rpmOstree:        true,
 		bootISO:          false,
-		pipelines:        iotContainerPipelines,
+		manifest:         iotContainerManifest,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"ostree-tree", "ostree-commit", "container-tree", "container"},
 		exports:          []string{"container"},
@@ -128,7 +128,7 @@ var (
 		},
 		rpmOstree:        true,
 		bootISO:          true,
-		pipelines:        iotInstallerPipelines,
+		manifest:         iotInstallerManifest,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"anaconda-tree", "bootiso-tree", "bootiso"},
 		exports:          []string{"bootiso"},
@@ -156,7 +156,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		pipelines:           qcow2Pipelines,
+		manifest:            qcow2Manifest,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "qcow2"},
 		exports:             []string{"qcow2"},
@@ -188,7 +188,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		pipelines:           vhdPipelines,
+		manifest:            vhdManifest,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "vpc"},
 		exports:             []string{"vpc"},
@@ -217,7 +217,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		pipelines:           vmdkPipelines,
+		manifest:            vmdkManifest,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "vmdk"},
 		exports:             []string{"vmdk"},
@@ -246,7 +246,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		pipelines:           openstackPipelines,
+		manifest:            openstackManifest,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "qcow2"},
 		exports:             []string{"qcow2"},
@@ -276,7 +276,7 @@ var (
 		bootable:            true,
 		bootType:            distro.LegacyBootType,
 		defaultSize:         6 * GigaByte,
-		pipelines:           ec2Pipelines,
+		manifest:            ec2Manifest,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image"},
 		exports:             []string{"image"},
@@ -299,7 +299,7 @@ var (
 			Locale:      "C.UTF-8",
 			Timezone:    "Etc/UTC",
 		},
-		pipelines:        containerPipelines,
+		manifest:         containerManifest,
 		bootable:         false,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"os", "container"},
@@ -478,7 +478,7 @@ func (a *architecture) Distro() distro.Distro {
 	return a.distro
 }
 
-type pipelinesFunc func(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error)
+type manifestFunc func(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) error
 
 type packageSetFunc func(t *imageType) rpmmd.PackageSet
 
@@ -496,7 +496,7 @@ type imageType struct {
 	buildPipelines     []string
 	payloadPipelines   []string
 	exports            []string
-	pipelines          pipelinesFunc
+	manifest           manifestFunc
 
 	// bootISO: installable ISO
 	bootISO bool
@@ -711,7 +711,7 @@ func (t *imageType) initializeManifest(customizations *blueprint.Customizations,
 	rng := rand.New(source)
 
 	manifest := manifest.New()
-	_, err := t.pipelines(&manifest, t, customizations, options, repos, packageSetChains, rng)
+	err := t.manifest(&manifest, t, customizations, options, repos, packageSetChains, rng)
 	if err != nil {
 		return nil, err
 	}
diff --git a/internal/distro/fedora/manifests.go b/internal/distro/fedora/manifests.go
new file mode 100644
index 000000000..06c10e492
--- /dev/null
+++ b/internal/distro/fedora/manifests.go
@@ -0,0 +1,445 @@
+package fedora
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+)
+
+func qcow2Manifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return err
+	}
+	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
+	qcow2Pipeline := manifest.NewQCOW2Pipeline(m, buildPipeline, imagePipeline, t.filename)
+	qcow2Pipeline.Compat = "1.1"
+
+	return nil
+}
+
+func vhdManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return err
+	}
+	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
+	manifest.NewVPCPipeline(m, buildPipeline, imagePipeline, t.filename)
+
+	return nil
+}
+
+func vmdkManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return err
+	}
+	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
+	manifest.NewVMDKPipeline(m, buildPipeline, imagePipeline, t.filename)
+
+	return nil
+}
+
+func openstackManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return err
+	}
+	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
+	manifest.NewQCOW2Pipeline(m, buildPipeline, imagePipeline, t.filename)
+
+	return nil
+}
+
+func ec2CommonManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand,
+	diskfile string) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return nil
+	}
+	manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, diskfile)
+
+	return nil
+}
+
+// ec2Manifest returns a manifest which produce uncompressed EC2 images which are expected to use RHSM for content
+func ec2Manifest(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
+	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+	return ec2CommonManifest(m, t, customizations, options, repos, packageSetChains, rng, t.Filename())
+}
+
+func iotInstallerManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+
+	d := t.arch.distro
+	ksUsers := len(customizations.GetUsers())+len(customizations.GetGroups()) > 0
+
+	anacondaTreePipeline := anacondaTreePipeline(m, buildPipeline, repos, packageSetChains[installerPkgsKey], t.Arch().Name(), d.product, d.osVersion, "IoT", ksUsers)
+	isoTreePipeline := bootISOTreePipeline(m, buildPipeline, anacondaTreePipeline, options, d.vendor, d.isolabelTmpl, customizations.GetUsers(), customizations.GetGroups())
+	bootISOPipeline(m, buildPipeline, isoTreePipeline, t.Filename(), false)
+
+	return nil
+}
+
+func iotCorePipelines(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet) (*manifest.BuildPipeline,
+	*manifest.OSTreeCommitPipeline,
+	error) {
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+	commitPipeline := ostreeCommitPipeline(m, buildPipeline, treePipeline, options, t.arch.distro.osVersion)
+
+	return buildPipeline, commitPipeline, nil
+}
+
+func iotCommitManifest(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
+	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline, commitPipeline, err := iotCorePipelines(m, t, customizations, options, repos, packageSetChains)
+	if err != nil {
+		return err
+	}
+	manifest.NewTarPipeline(m, buildPipeline, &commitPipeline.BasePipeline, "commit-archive", t.Filename())
+
+	return nil
+}
+
+func iotContainerManifest(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations,
+	options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+	buildPipeline, commitPipeline, err := iotCorePipelines(m, t, customizations, options, repos, packageSetChains)
+	if err != nil {
+		return err
+	}
+
+	nginxConfigPath := "/etc/nginx.conf"
+	httpPort := "8080"
+	containerTreePipeline := containerTreePipeline(m, buildPipeline, commitPipeline, repos, packageSetChains[containerPkgsKey], options, customizations, nginxConfigPath, httpPort)
+	containerPipeline(m, buildPipeline, &containerTreePipeline.BasePipeline, t, nginxConfigPath, httpPort)
+
+	return nil
+}
+
+func containerManifest(m *manifest.Manifest,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	repos []rpmmd.RepoConfig,
+	packageSetChains map[string][]rpmmd.PackageSet,
+	rng *rand.Rand) error {
+
+	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
+	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
+	if err != nil {
+		return err
+	}
+	manifest.NewOCIContainerPipeline(m, buildPipeline, &treePipeline.BasePipeline, t.Arch().Name(), t.Filename())
+
+	return nil
+}
+
+func osPipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	t *imageType,
+	repos []rpmmd.RepoConfig,
+	osChain []rpmmd.PackageSet,
+	c *blueprint.Customizations,
+	options distro.ImageOptions,
+	rng *rand.Rand) (*manifest.OSPipeline, error) {
+
+	imageConfig := t.getDefaultImageConfig()
+
+	var arch manifest.Arch
+	switch t.Arch().Name() {
+	case distro.X86_64ArchName:
+		arch = manifest.ARCH_X86_64
+	case distro.Aarch64ArchName:
+		arch = manifest.ARCH_AARCH64
+	case distro.Ppc64leArchName:
+		arch = manifest.ARCH_PPC64LE
+	case distro.S390xArchName:
+		arch = manifest.ARCH_S390X
+	}
+
+	pl := manifest.NewOSPipeline(m, buildPipeline, arch, repos)
+
+	if t.bootable {
+		var err error
+		pt, err := t.getPartitionTable(c.GetFilesystems(), options, rng)
+		if err != nil {
+			return nil, err
+		}
+		pl.PartitionTable = pt
+
+		if t.supportsUEFI() {
+			pl.UEFIVendor = t.arch.distro.vendor
+		}
+
+		pl.BIOSPlatform = t.arch.legacy
+
+		pl.KernelName = c.GetKernel().Name
+
+		var kernelOptions []string
+		if t.kernelOptions != "" {
+			kernelOptions = append(kernelOptions, t.kernelOptions)
+		}
+		if bpKernel := c.GetKernel(); bpKernel.Append != "" {
+			kernelOptions = append(kernelOptions, bpKernel.Append)
+		}
+		pl.KernelOptionsAppend = kernelOptions
+	}
+
+	if t.rpmOstree {
+		var parent *manifest.OSPipelineOSTreeParent
+		if options.OSTree.Parent != "" && options.OSTree.URL != "" {
+			parent = &manifest.OSPipelineOSTreeParent{
+				Checksum: options.OSTree.Parent,
+				URL:      options.OSTree.URL,
+			}
+		}
+		pl.OSTree = &manifest.OSPipelineOSTree{
+			Parent: parent,
+		}
+	}
+
+	if len(osChain) >= 1 {
+		pl.ExtraBasePackages = osChain[0].Include
+		pl.ExcludeBasePackages = osChain[0].Exclude
+	}
+	if len(osChain) >= 2 {
+		pl.UserPackages = osChain[1].Include
+		pl.UserRepos = osChain[1].Repositories
+	}
+	if len(osChain) > 2 {
+		panic("unexpected number of package sets in os chain")
+	}
+
+	pl.GPGKeyFiles = imageConfig.GPGKeyFiles
+	pl.ExcludeDocs = imageConfig.ExcludeDocs
+
+	if !t.bootISO {
+		// don't put users and groups in the payload of an installer
+		// add them via kickstart instead
+		pl.Groups = c.GetGroups()
+		pl.Users = c.GetUsers()
+	}
+
+	services := &blueprint.ServicesCustomization{
+		Enabled:  imageConfig.EnabledServices,
+		Disabled: imageConfig.DisabledServices,
+	}
+	if extraServices := c.GetServices(); extraServices != nil {
+		services.Enabled = append(services.Enabled, extraServices.Enabled...)
+		services.Disabled = append(services.Disabled, extraServices.Disabled...)
+	}
+	pl.EnabledServices = services.Enabled
+	pl.DisabledServices = services.Disabled
+	pl.DefaultTarget = imageConfig.DefaultTarget
+
+	pl.Firewall = c.GetFirewall()
+
+	language, keyboard := c.GetPrimaryLocale()
+	if language != nil {
+		pl.Language = *language
+	} else {
+		pl.Language = imageConfig.Locale
+	}
+	if keyboard != nil {
+		pl.Keyboard = keyboard
+	} else if imageConfig.Keyboard != nil {
+		pl.Keyboard = &imageConfig.Keyboard.Keymap
+	}
+
+	if hostname := c.GetHostname(); hostname != nil {
+		pl.Hostname = *hostname
+	}
+
+	timezone, ntpServers := c.GetTimezoneSettings()
+	if timezone != nil {
+		pl.Timezone = *timezone
+	} else {
+		pl.Timezone = imageConfig.Timezone
+	}
+
+	if len(ntpServers) > 0 {
+		pl.NTPServers = ntpServers
+	} else if imageConfig.TimeSynchronization != nil {
+		pl.NTPServers = imageConfig.TimeSynchronization.Timeservers
+	}
+
+	if imageConfig.NoSElinux {
+		pl.SElinux = ""
+	}
+
+	pl.Grub2Config = imageConfig.Grub2Config
+	pl.Sysconfig = imageConfig.Sysconfig
+	pl.SystemdLogind = imageConfig.SystemdLogind
+	pl.CloudInit = imageConfig.CloudInit
+	pl.Modprobe = imageConfig.Modprobe
+	pl.DracutConf = imageConfig.DracutConf
+	pl.SystemdUnit = imageConfig.SystemdUnit
+	pl.Authselect = imageConfig.Authselect
+	pl.SELinuxConfig = imageConfig.SELinuxConfig
+	pl.Tuned = imageConfig.Tuned
+	pl.Tmpfilesd = imageConfig.Tmpfilesd
+	pl.PamLimitsConf = imageConfig.PamLimitsConf
+	pl.Sysctld = imageConfig.Sysctld
+	pl.DNFConfig = imageConfig.DNFConfig
+	pl.SshdConfig = imageConfig.SshdConfig
+	pl.AuthConfig = imageConfig.Authconfig
+	pl.PwQuality = imageConfig.PwQuality
+	pl.WAAgentConfig = imageConfig.WAAgentConfig
+
+	return pl, nil
+}
+
+func ostreeCommitPipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	treePipeline *manifest.OSPipeline,
+	options distro.ImageOptions,
+	osVersion string) *manifest.OSTreeCommitPipeline {
+	p := manifest.NewOSTreeCommitPipeline(m, buildPipeline, treePipeline, options.OSTree.Ref)
+	p.OSVersion = osVersion
+	return p
+}
+
+func containerTreePipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	commitPipeline *manifest.OSTreeCommitPipeline,
+	repos []rpmmd.RepoConfig,
+	containerChain []rpmmd.PackageSet,
+	options distro.ImageOptions,
+	c *blueprint.Customizations,
+	nginxConfigPath,
+	listenPort string) *manifest.OSTreeCommitServerTreePipeline {
+	p := manifest.NewOSTreeCommitServerTreePipeline(m, buildPipeline, repos, commitPipeline, nginxConfigPath, listenPort)
+	if len(containerChain) >= 1 {
+		p.ExtraPackages = containerChain[0].Include
+	}
+	if len(containerChain) > 2 {
+		panic("unexpected number of package sets in os chain")
+	}
+	language, _ := c.GetPrimaryLocale()
+	if language != nil {
+		p.Language = *language
+	}
+	return p
+}
+
+func containerPipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	treePipeline *manifest.BasePipeline,
+	t *imageType,
+	nginxConfigPath,
+	listenPort string) *manifest.OCIContainerPipeline {
+	p := manifest.NewOCIContainerPipeline(m, buildPipeline, treePipeline, t.Arch().Name(), t.Filename())
+	p.Cmd = []string{"nginx", "-c", nginxConfigPath}
+	p.ExposedPorts = []string{listenPort}
+	return p
+}
+
+func anacondaTreePipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	repos []rpmmd.RepoConfig,
+	chain []rpmmd.PackageSet,
+	arch, product, osVersion, variant string,
+	users bool) *manifest.AnacondaPipeline {
+	p := manifest.NewAnacondaPipeline(m, buildPipeline, repos, "kernel", arch, product, osVersion)
+	if len(chain) >= 1 {
+		p.ExtraPackages = chain[0].Include
+	}
+	if len(chain) > 1 {
+		panic("unexpected number of package sets in installer chain")
+	}
+	p.Users = users
+	p.Variant = variant
+	p.Biosdevname = (arch == distro.X86_64ArchName)
+	return p
+}
+
+func bootISOTreePipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	anacondaPipeline *manifest.AnacondaPipeline,
+	options distro.ImageOptions,
+	vendor,
+	isoLabelTempl string,
+	users []blueprint.UserCustomization,
+	groups []blueprint.GroupCustomization) *manifest.ISOTreePipeline {
+	p := manifest.NewISOTreePipeline(m, buildPipeline, anacondaPipeline, options.OSTree.Parent, options.OSTree.URL, options.OSTree.Ref, isoLabelTempl)
+	p.Release = "202010217.n.0"
+	p.OSName = "fedora"
+	p.UEFIVendor = vendor
+	p.Users = users
+	p.Groups = groups
+
+	return p
+}
+
+func bootISOPipeline(m *manifest.Manifest,
+	buildPipeline *manifest.BuildPipeline,
+	treePipeline *manifest.ISOTreePipeline,
+	filename string,
+	isolinux bool) *manifest.ISOPipeline {
+	p := manifest.NewISOPipeline(m, buildPipeline, treePipeline, filename)
+	p.ISOLinux = isolinux
+	return p
+}
diff --git a/internal/distro/fedora/pipelines.go b/internal/distro/fedora/pipelines.go
deleted file mode 100644
index 903554c4e..000000000
--- a/internal/distro/fedora/pipelines.go
+++ /dev/null
@@ -1,399 +0,0 @@
-package fedora
-
-import (
-	"math/rand"
-
-	"github.com/osbuild/osbuild-composer/internal/blueprint"
-	"github.com/osbuild/osbuild-composer/internal/disk"
-	"github.com/osbuild/osbuild-composer/internal/distro"
-	"github.com/osbuild/osbuild-composer/internal/manifest"
-	"github.com/osbuild/osbuild-composer/internal/rpmmd"
-)
-
-func qcow2Pipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
-	pipelines = append(pipelines, imagePipeline)
-
-	qcow2Pipeline := manifest.NewQCOW2Pipeline(m, buildPipeline, imagePipeline, t.filename)
-	qcow2Pipeline.Compat = "1.1"
-	pipelines = append(pipelines, qcow2Pipeline)
-
-	return pipelines, nil
-}
-
-func vhdPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
-	pipelines = append(pipelines, imagePipeline)
-
-	vpcPipeline := manifest.NewVPCPipeline(m, buildPipeline, imagePipeline, t.filename)
-	pipelines = append(pipelines, vpcPipeline)
-	return pipelines, nil
-}
-
-func vmdkPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
-	pipelines = append(pipelines, imagePipeline)
-
-	vmdkPipeline := manifest.NewVMDKPipeline(m, buildPipeline, imagePipeline, t.filename)
-	pipelines = append(pipelines, vmdkPipeline)
-	return pipelines, nil
-}
-
-func openstackPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, "disk.img")
-	pipelines = append(pipelines, imagePipeline)
-
-	qcow2Pipeline := manifest.NewQCOW2Pipeline(m, buildPipeline, imagePipeline, t.filename)
-	pipelines = append(pipelines, qcow2Pipeline)
-	return pipelines, nil
-}
-
-func ec2CommonPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
-	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
-	rng *rand.Rand, diskfile string) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	imagePipeline := manifest.NewLiveImgPipeline(m, buildPipeline, treePipeline, diskfile)
-	pipelines = append(pipelines, imagePipeline)
-	return pipelines, nil
-}
-
-// ec2Pipelines returns pipelines which produce uncompressed EC2 images which are expected to use RHSM for content
-func ec2Pipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
-	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
-	rng *rand.Rand) ([]manifest.Pipeline, error) {
-	return ec2CommonPipelines(m, t, customizations, options, repos, packageSetChains, rng, t.Filename())
-}
-
-func iotInstallerPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
-	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
-	rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	d := t.arch.distro
-	ksUsers := len(customizations.GetUsers())+len(customizations.GetGroups()) > 0
-
-	anacondaTreePipeline := anacondaTreePipeline(m, buildPipeline, repos, t.Arch().Name(), d.product, d.osVersion, "IoT", ksUsers)
-	installerChain := packageSetChains[installerPkgsKey]
-	if len(installerChain) >= 1 {
-		anacondaTreePipeline.ExtraPackages = installerChain[0].Include
-	}
-	if len(installerChain) > 1 {
-		panic("unexpected number of package sets in installer chain")
-	}
-	isoTreePipeline := bootISOTreePipeline(m, buildPipeline, anacondaTreePipeline, options, d.vendor, d.isolabelTmpl, customizations.GetUsers(), customizations.GetGroups())
-	isoPipeline := bootISOPipeline(m, buildPipeline, isoTreePipeline, t.Filename(), false)
-
-	return append(pipelines, anacondaTreePipeline, isoTreePipeline, isoPipeline), nil
-}
-
-func iotCorePipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
-	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet) (*manifest.BuildPipeline, *manifest.OSPipeline, *manifest.OSTreeCommitPipeline, error) {
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, nil)
-	if err != nil {
-		return nil, nil, nil, err
-	}
-	commitPipeline := ostreeCommitPipeline(m, buildPipeline, treePipeline, options, t.arch.distro.osVersion)
-
-	return buildPipeline, treePipeline, commitPipeline, nil
-}
-
-func iotCommitPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions,
-	repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
-	rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline, treePipeline, commitPipeline, err := iotCorePipelines(m, t, customizations, options, repos, packageSetChains)
-	if err != nil {
-		return nil, err
-	}
-	tarPipeline := manifest.NewTarPipeline(m, buildPipeline, &commitPipeline.BasePipeline, "commit-archive", t.Filename())
-	pipelines = append(pipelines, buildPipeline, treePipeline, commitPipeline, tarPipeline)
-	return pipelines, nil
-}
-
-func iotContainerPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations,
-	options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet,
-	rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline, treePipeline, commitPipeline, err := iotCorePipelines(m, t, customizations, options, repos, packageSetChains)
-	if err != nil {
-		return nil, err
-	}
-
-	nginxConfigPath := "/etc/nginx.conf"
-	httpPort := "8080"
-	containerTreePipeline := containerTreePipeline(m, buildPipeline, commitPipeline, repos, packageSetChains[containerPkgsKey], options, customizations, nginxConfigPath, httpPort)
-	containerPipeline := containerPipeline(m, buildPipeline, &containerTreePipeline.BasePipeline, t, nginxConfigPath, httpPort)
-
-	pipelines = append(pipelines, buildPipeline, treePipeline, commitPipeline, containerTreePipeline, containerPipeline)
-	return pipelines, nil
-}
-
-func osPipeline(m *manifest.Manifest,
-	buildPipeline *manifest.BuildPipeline,
-	t *imageType,
-	repos []rpmmd.RepoConfig,
-	osChain []rpmmd.PackageSet,
-	c *blueprint.Customizations,
-	options distro.ImageOptions,
-	rng *rand.Rand) (*manifest.OSPipeline, error) {
-
-	imageConfig := t.getDefaultImageConfig()
-
-	var pt *disk.PartitionTable
-	if t.bootable {
-		// TODO: should there always be a partition table?
-		var err error
-		pt, err = t.getPartitionTable(c.GetFilesystems(), options, rng)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	var bootLoader manifest.BootLoader
-	if t.Arch().Name() == distro.S390xArchName {
-		bootLoader = manifest.BOOTLOADER_ZIPL
-	} else {
-		bootLoader = manifest.BOOTLOADER_GRUB
-	}
-
-	var kernelName string
-	if t.bootable {
-		kernelName = c.GetKernel().Name
-	}
-
-	pl := manifest.NewOSPipeline(m, buildPipeline, t.rpmOstree, options.OSTree.Parent, options.OSTree.URL, repos, pt, bootLoader, t.arch.legacy, kernelName)
-
-	if len(osChain) >= 1 {
-		pl.ExtraBasePackages = osChain[0].Include
-		pl.ExcludeBasePackages = osChain[0].Exclude
-	}
-	if len(osChain) >= 2 {
-		pl.UserPackages = osChain[1].Include
-		pl.UserRepos = osChain[1].Repositories
-	}
-	if len(osChain) > 2 {
-		panic("unexpected number of package sets in os chain")
-	}
-
-	if t.supportsUEFI() {
-		pl.UEFIVendor = t.arch.distro.vendor
-	}
-
-	var kernelOptions []string
-	if t.kernelOptions != "" {
-		kernelOptions = append(kernelOptions, t.kernelOptions)
-	}
-	if bpKernel := c.GetKernel(); bpKernel.Append != "" {
-		kernelOptions = append(kernelOptions, bpKernel.Append)
-	}
-	pl.KernelOptionsAppend = kernelOptions
-
-	pl.GPGKeyFiles = imageConfig.GPGKeyFiles
-	pl.ExcludeDocs = imageConfig.ExcludeDocs
-
-	if !t.bootISO {
-		// don't put users and groups in the payload of an installer
-		// add them via kickstart instead
-		pl.Groups = c.GetGroups()
-		pl.Users = c.GetUsers()
-	}
-
-	services := &blueprint.ServicesCustomization{
-		Enabled:  imageConfig.EnabledServices,
-		Disabled: imageConfig.DisabledServices,
-	}
-	if extraServices := c.GetServices(); extraServices != nil {
-		services.Enabled = append(services.Enabled, extraServices.Enabled...)
-		services.Disabled = append(services.Disabled, extraServices.Disabled...)
-	}
-	pl.EnabledServices = services.Enabled
-	pl.DisabledServices = services.Disabled
-	pl.DefaultTarget = imageConfig.DefaultTarget
-
-	pl.Firewall = c.GetFirewall()
-
-	language, keyboard := c.GetPrimaryLocale()
-	if language != nil {
-		pl.Language = *language
-	} else {
-		pl.Language = imageConfig.Locale
-	}
-	if keyboard != nil {
-		pl.Keyboard = keyboard
-	} else if imageConfig.Keyboard != nil {
-		pl.Keyboard = &imageConfig.Keyboard.Keymap
-	}
-
-	if hostname := c.GetHostname(); hostname != nil {
-		pl.Hostname = *hostname
-	}
-
-	timezone, ntpServers := c.GetTimezoneSettings()
-	if timezone != nil {
-		pl.Timezone = *timezone
-	} else {
-		pl.Timezone = imageConfig.Timezone
-	}
-
-	if len(ntpServers) > 0 {
-		pl.NTPServers = ntpServers
-	} else if imageConfig.TimeSynchronization != nil {
-		pl.NTPServers = imageConfig.TimeSynchronization.Timeservers
-	}
-
-	if imageConfig.NoSElinux {
-		pl.SElinux = ""
-	}
-
-	pl.Grub2Config = imageConfig.Grub2Config
-	pl.Sysconfig = imageConfig.Sysconfig
-	pl.SystemdLogind = imageConfig.SystemdLogind
-	pl.CloudInit = imageConfig.CloudInit
-	pl.Modprobe = imageConfig.Modprobe
-	pl.DracutConf = imageConfig.DracutConf
-	pl.SystemdUnit = imageConfig.SystemdUnit
-	pl.Authselect = imageConfig.Authselect
-	pl.SELinuxConfig = imageConfig.SELinuxConfig
-	pl.Tuned = imageConfig.Tuned
-	pl.Tmpfilesd = imageConfig.Tmpfilesd
-	pl.PamLimitsConf = imageConfig.PamLimitsConf
-	pl.Sysctld = imageConfig.Sysctld
-	pl.DNFConfig = imageConfig.DNFConfig
-	pl.SshdConfig = imageConfig.SshdConfig
-	pl.AuthConfig = imageConfig.Authconfig
-	pl.PwQuality = imageConfig.PwQuality
-	pl.WAAgentConfig = imageConfig.WAAgentConfig
-
-	return pl, nil
-}
-
-func ostreeCommitPipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, treePipeline *manifest.OSPipeline, options distro.ImageOptions, osVersion string) *manifest.OSTreeCommitPipeline {
-	p := manifest.NewOSTreeCommitPipeline(m, buildPipeline, treePipeline, options.OSTree.Ref)
-	p.OSVersion = osVersion
-	return p
-}
-
-func containerTreePipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, commitPipeline *manifest.OSTreeCommitPipeline, repos []rpmmd.RepoConfig, containerChain []rpmmd.PackageSet, options distro.ImageOptions, c *blueprint.Customizations, nginxConfigPath, listenPort string) *manifest.OSTreeCommitServerTreePipeline {
-	p := manifest.NewOSTreeCommitServerTreePipeline(m, buildPipeline, repos, commitPipeline, nginxConfigPath, listenPort)
-	if len(containerChain) >= 1 {
-		p.ExtraPackages = containerChain[0].Include
-	}
-	if len(containerChain) > 2 {
-		panic("unexpected number of package sets in os chain")
-	}
-	language, _ := c.GetPrimaryLocale()
-	if language != nil {
-		p.Language = *language
-	}
-	return p
-}
-
-func containerPipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, treePipeline *manifest.BasePipeline, t *imageType, nginxConfigPath, listenPort string) *manifest.OCIContainerPipeline {
-	p := manifest.NewOCIContainerPipeline(m, buildPipeline, treePipeline, t.Arch().Name(), t.Filename())
-	p.Cmd = []string{"nginx", "-c", nginxConfigPath}
-	p.ExposedPorts = []string{listenPort}
-	return p
-}
-
-func anacondaTreePipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, repos []rpmmd.RepoConfig, arch, product, osVersion, variant string, users bool) *manifest.AnacondaPipeline {
-	p := manifest.NewAnacondaPipeline(m, buildPipeline, repos, "kernel", arch, product, osVersion)
-	p.Users = users
-	p.Variant = variant
-	p.Biosdevname = (arch == distro.X86_64ArchName)
-	return p
-}
-
-func bootISOTreePipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, anacondaPipeline *manifest.AnacondaPipeline, options distro.ImageOptions, vendor, isoLabelTempl string, users []blueprint.UserCustomization, groups []blueprint.GroupCustomization) *manifest.ISOTreePipeline {
-	p := manifest.NewISOTreePipeline(m, buildPipeline, anacondaPipeline, options.OSTree.Parent, options.OSTree.URL, options.OSTree.Ref, isoLabelTempl)
-	p.Release = "202010217.n.0"
-	p.OSName = "fedora"
-	p.UEFIVendor = vendor
-	p.Users = users
-	p.Groups = groups
-
-	return p
-}
-
-func bootISOPipeline(m *manifest.Manifest, buildPipeline *manifest.BuildPipeline, treePipeline *manifest.ISOTreePipeline, filename string, isolinux bool) *manifest.ISOPipeline {
-	p := manifest.NewISOPipeline(m, buildPipeline, treePipeline, filename)
-	p.ISOLinux = isolinux
-	return p
-}
-
-func containerPipelines(m *manifest.Manifest, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSetChains map[string][]rpmmd.PackageSet, rng *rand.Rand) ([]manifest.Pipeline, error) {
-	pipelines := make([]manifest.Pipeline, 0)
-
-	buildPipeline := manifest.NewBuildPipeline(m, t.arch.distro.runner, repos)
-	pipelines = append(pipelines, buildPipeline)
-
-	treePipeline, err := osPipeline(m, buildPipeline, t, repos, packageSetChains[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil, err
-	}
-	pipelines = append(pipelines, treePipeline)
-
-	ociPipeline := manifest.NewOCIContainerPipeline(m, buildPipeline, &treePipeline.BasePipeline, t.Arch().Name(), t.Filename())
-	pipelines = append(pipelines, ociPipeline)
-
-	return pipelines, nil
-}
diff --git a/internal/manifest/commit.go b/internal/manifest/commit.go
index 8b2bfdf1b..e22040327 100644
--- a/internal/manifest/commit.go
+++ b/internal/manifest/commit.go
@@ -43,6 +43,10 @@ func (p *OSTreeCommitPipeline) getBuildPackages() []string {
 func (p *OSTreeCommitPipeline) serialize() osbuild2.Pipeline {
 	pipeline := p.BasePipeline.serialize()
 
+	if p.treePipeline.OSTree == nil {
+		panic("tree is not ostree")
+	}
+
 	pipeline.AddStage(osbuild2.NewOSTreeInitStage(&osbuild2.OSTreeInitStageOptions{Path: "/repo"}))
 
 	commitStageInput := new(osbuild2.OSTreeCommitStageInput)
@@ -50,11 +54,15 @@ func (p *OSTreeCommitPipeline) serialize() osbuild2.Pipeline {
 	commitStageInput.Origin = "org.osbuild.pipeline"
 	commitStageInput.References = osbuild2.OSTreeCommitStageReferences{"name:" + p.treePipeline.Name()}
 
+	var parent string
+	if p.treePipeline.OSTree.Parent != nil {
+		parent = p.treePipeline.OSTree.Parent.Checksum
+	}
 	pipeline.AddStage(osbuild2.NewOSTreeCommitStage(
 		&osbuild2.OSTreeCommitStageOptions{
 			Ref:       p.ref,
 			OSVersion: p.OSVersion,
-			Parent:    p.treePipeline.osTreeParent,
+			Parent:    parent,
 		},
 		&osbuild2.OSTreeCommitStageInputs{Tree: commitStageInput}),
 	)
diff --git a/internal/manifest/live.go b/internal/manifest/live.go
index 802633419..1260704b9 100644
--- a/internal/manifest/live.go
+++ b/internal/manifest/live.go
@@ -33,7 +33,7 @@ func NewLiveImgPipeline(m *Manifest,
 func (p *LiveImgPipeline) serialize() osbuild2.Pipeline {
 	pipeline := p.BasePipeline.serialize()
 
-	pt := p.treePipeline.partitionTable
+	pt := p.treePipeline.PartitionTable
 	if pt == nil {
 		panic("no partition table in live image")
 	}
@@ -51,14 +51,14 @@ func (p *LiveImgPipeline) serialize() osbuild2.Pipeline {
 		pipeline.AddStage(stage)
 	}
 
-	switch p.treePipeline.bootLoader {
-	case BOOTLOADER_GRUB:
-		if grubLegacy := p.treePipeline.grubLegacy; grubLegacy != "" {
-			pipeline.AddStage(osbuild2.NewGrub2InstStage(osbuild2.NewGrub2InstStageOption(p.filename, pt, grubLegacy)))
-		}
-	case BOOTLOADER_ZIPL:
+	switch p.treePipeline.arch {
+	case ARCH_S390X:
 		loopback := osbuild2.NewLoopbackDevice(&osbuild2.LoopbackDeviceOptions{Filename: p.filename})
 		pipeline.AddStage(osbuild2.NewZiplInstStage(osbuild2.NewZiplInstStageOptions(p.treePipeline.kernelVer, pt), loopback, copyDevices, copyMounts))
+	default:
+		if grubLegacy := p.treePipeline.BIOSPlatform; grubLegacy != "" {
+			pipeline.AddStage(osbuild2.NewGrub2InstStage(osbuild2.NewGrub2InstStageOption(p.filename, pt, grubLegacy)))
+		}
 	}
 
 	return pipeline
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index 07fa34c8b..d6ad42a9a 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -9,6 +9,15 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
+type Arch uint64
+
+const (
+	ARCH_X86_64 Arch = iota
+	ARCH_AARCH64
+	ARCH_S390X
+	ARCH_PPC64LE
+)
+
 type osTreeCommit struct {
 	checksum string
 	url      string
diff --git a/internal/manifest/os.go b/internal/manifest/os.go
index 13e4201a7..06064bee3 100644
--- a/internal/manifest/os.go
+++ b/internal/manifest/os.go
@@ -12,12 +12,14 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
-type BootLoader uint64
+type OSPipelineOSTree struct {
+	Parent *OSPipelineOSTreeParent
+}
 
-const (
-	BOOTLOADER_GRUB BootLoader = iota
-	BOOTLOADER_ZIPL
-)
+type OSPipelineOSTreeParent struct {
+	Checksum string
+	URL      string
+}
 
 // OSPipeline represents the filesystem tree of the target image. This roughly
 // correpsonds to the root filesystem once an instance of the image is running.
@@ -36,11 +38,21 @@ type OSPipeline struct {
 	UserPackages []string
 	// Repositories to install the user packages from.
 	UserRepos []rpmmd.RepoConfig
+	// OSTree configuration, if nil the tree cannot be in an OSTree commit
+	OSTree *OSPipelineOSTree
+	// Partition table, if nil the tree cannot be put on a partioned disk
+	PartitionTable *disk.PartitionTable
+	// KernelName indicates that a kernel is installed, and names the kernel
+	// package.
+	KernelName string
 	// KernelOptionsAppend are appended to the kernel commandline
 	KernelOptionsAppend []string
-	// UEFIVendor indicates whether or not the OS should support UEFI and
+	// UEFIVendor indicates whether or not the image should support UEFI and
 	// if set namespaces the UEFI binaries with this string.
 	UEFIVendor string
+	// BIOSPlatform indicates whether or not the image should support BIOS
+	// booting and if set, the name of the platform, e.g., i386-pc
+	BIOSPlatform string
 	// GPGKeyFiles are a list of filenames in the OS which will be imported
 	// as GPG keys into the RPM database.
 	GPGKeyFiles      []string
@@ -84,16 +96,10 @@ type OSPipeline struct {
 	PwQuality     *osbuild2.PwqualityConfStageOptions
 	WAAgentConfig *osbuild2.WAAgentConfStageOptions
 
-	osTree         bool
-	osTreeParent   string
-	osTreeURL      string
-	repos          []rpmmd.RepoConfig
-	packageSpecs   []rpmmd.PackageSpec
-	partitionTable *disk.PartitionTable
-	bootLoader     BootLoader
-	grubLegacy     string
-	kernelName     string
-	kernelVer      string
+	repos        []rpmmd.RepoConfig
+	packageSpecs []rpmmd.PackageSpec
+	arch         Arch
+	kernelVer    string
 }
 
 // NewOSPipeline creates a new OS pipeline. osTree indicates whether or not the
@@ -104,32 +110,15 @@ type OSPipeline struct {
 // kernel package that will be used on the target system.
 func NewOSPipeline(m *Manifest,
 	buildPipeline *BuildPipeline,
-	osTree bool,
-	osTreeParent string,
-	osTreeURL string,
-	repos []rpmmd.RepoConfig,
-	partitionTable *disk.PartitionTable,
-	bootLoader BootLoader,
-	grubLegacy string,
-	kernelName string) *OSPipeline {
-	name := "os"
-	if osTree {
-		name = "ostree-tree"
-	}
+	arch Arch,
+	repos []rpmmd.RepoConfig) *OSPipeline {
 	p := &OSPipeline{
-		BasePipeline:   NewBasePipeline(m, name, buildPipeline, nil),
-		osTree:         osTree,
-		osTreeParent:   osTreeParent,
-		osTreeURL:      osTreeURL,
-		repos:          repos,
-		partitionTable: partitionTable,
-		bootLoader:     bootLoader,
-		grubLegacy:     grubLegacy,
-		kernelName:     kernelName,
-		Language:       "C.UTF-8",
-		Hostname:       "localhost.localdomain",
-		Timezone:       "UTC",
-		SElinux:        "targeted",
+		BasePipeline: NewBasePipeline(m, "os", buildPipeline, nil),
+		repos:        repos,
+		Language:     "C.UTF-8",
+		Hostname:     "localhost.localdomain",
+		Timezone:     "UTC",
+		SElinux:      "targeted",
 	}
 	buildPipeline.addDependent(p)
 	m.addPipeline(p)
@@ -158,10 +147,10 @@ func (p *OSPipeline) getPackageSetChain() []rpmmd.PackageSet {
 
 func (p *OSPipeline) getBuildPackages() []string {
 	packages := []string{}
-	if p.grubLegacy != "" {
+	if p.BIOSPlatform != "" {
 		packages = append(packages, "grub2-pc")
 	}
-	if p.osTree {
+	if p.OSTree != nil {
 		packages = append(packages, "rpm-ostree")
 	}
 	return packages
@@ -169,10 +158,10 @@ func (p *OSPipeline) getBuildPackages() []string {
 
 func (p *OSPipeline) getOSTreeCommits() []osTreeCommit {
 	commits := []osTreeCommit{}
-	if p.osTreeParent != "" && p.osTreeURL != "" {
+	if p.OSTree != nil && p.OSTree.Parent != nil {
 		commits = append(commits, osTreeCommit{
-			checksum: p.osTreeParent,
-			url:      p.osTreeURL,
+			checksum: p.OSTree.Parent.Checksum,
+			url:      p.OSTree.Parent.URL,
 		})
 	}
 	return commits
@@ -187,8 +176,8 @@ func (p *OSPipeline) serializeStart(packages []rpmmd.PackageSpec) {
 		panic("double call to serializeStart()")
 	}
 	p.packageSpecs = packages
-	if p.kernelName != "" {
-		p.kernelVer = rpmmd.GetVerStrFromPackageSpecListPanic(p.packageSpecs, p.kernelName)
+	if p.KernelName != "" {
+		p.kernelVer = rpmmd.GetVerStrFromPackageSpecListPanic(p.packageSpecs, p.KernelName)
 	}
 }
 
@@ -207,8 +196,8 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 
 	pipeline := p.BasePipeline.serialize()
 
-	if p.osTree && p.osTreeParent != "" {
-		pipeline.AddStage(osbuild2.NewOSTreePasswdStage("org.osbuild.source", p.osTreeParent))
+	if p.OSTree != nil && p.OSTree.Parent != nil {
+		pipeline.AddStage(osbuild2.NewOSTreePasswdStage("org.osbuild.source", p.OSTree.Parent.Checksum))
 	}
 
 	rpmOptions := osbuild2.NewRPMStageOptions(p.repos)
@@ -222,7 +211,7 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 	pipeline.AddStage(osbuild2.NewRPMStage(rpmOptions, osbuild2.NewRpmStageSourceFilesInputs(p.packageSpecs)))
 
 	// If the /boot is on a separate partition, the prefix for the BLS stage must be ""
-	if p.partitionTable == nil || p.partitionTable.FindMountable("/boot") == nil {
+	if p.PartitionTable == nil || p.PartitionTable.FindMountable("/boot") == nil {
 		pipeline.AddStage(osbuild2.NewFixBLSStage(&osbuild2.FixBLSStageOptions{}))
 	} else {
 		pipeline.AddStage(osbuild2.NewFixBLSStage(&osbuild2.FixBLSStageOptions{Prefix: common.StringToPtr("")}))
@@ -251,7 +240,7 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 			// TODO: move encryption into weldr
 			panic("password encryption failed")
 		}
-		if p.osTree {
+		if p.OSTree != nil {
 			// for ostree, writing the key during user creation is
 			// redundant and can cause issues so create users without keys
 			// and write them on first boot
@@ -357,17 +346,19 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 		pipeline.AddStage(osbuild2.NewWAAgentConfStage(p.WAAgentConfig))
 	}
 
-	if pt := p.partitionTable; pt != nil {
-		kernelOptions := osbuild2.GenImageKernelOptions(p.partitionTable)
+	if pt := p.PartitionTable; pt != nil {
+		kernelOptions := osbuild2.GenImageKernelOptions(p.PartitionTable)
 		kernelOptions = append(kernelOptions, p.KernelOptionsAppend...)
 		pipeline = prependKernelCmdlineStage(pipeline, strings.Join(kernelOptions, " "), pt)
 
 		pipeline.AddStage(osbuild2.NewFSTabStage(osbuild2.NewFSTabStageOptions(pt)))
 
 		var bootloader *osbuild2.Stage
-		switch p.bootLoader {
-		case BOOTLOADER_GRUB:
-			options := osbuild2.NewGrub2StageOptionsUnified(pt, p.kernelVer, p.UEFIVendor != "", p.grubLegacy, p.UEFIVendor, false)
+		switch p.arch {
+		case ARCH_S390X:
+			bootloader = osbuild2.NewZiplStage(new(osbuild2.ZiplStageOptions))
+		default:
+			options := osbuild2.NewGrub2StageOptionsUnified(pt, p.kernelVer, p.UEFIVendor != "", p.BIOSPlatform, p.UEFIVendor, false)
 			if cfg := p.Grub2Config; cfg != nil {
 				// TODO: don't store Grub2Config in OSPipeline, making the overrides unnecessary
 				// grub2.Config.Default is owned and set by `NewGrub2StageOptionsUnified`
@@ -379,10 +370,6 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 				options.Config = cfg
 			}
 			bootloader = osbuild2.NewGRUB2Stage(options)
-		case BOOTLOADER_ZIPL:
-			bootloader = osbuild2.NewZiplStage(new(osbuild2.ZiplStageOptions))
-		default:
-			panic("unknown bootloader")
 		}
 
 		pipeline.AddStage(bootloader)
@@ -394,7 +381,7 @@ func (p *OSPipeline) serialize() osbuild2.Pipeline {
 		}))
 	}
 
-	if p.osTree {
+	if p.OSTree != nil {
 		pipeline.AddStage(osbuild2.NewOSTreePrepTreeStage(&osbuild2.OSTreePrepTreeStageOptions{
 			EtcGroupMembers: []string{
 				// NOTE: We may want to make this configurable.
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index fbd8273cb..0c258a9f1 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -212,7 +212,13 @@ install -m 0755 -vp tools/generic_s3_https_test.sh                 %{buildroot}%
 install -m 0755 -vp tools/set-env-variables.sh                     %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/test-case-generators/generate-test-cases %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vd                                                %{buildroot}%{_libexecdir}/tests/osbuild-composer
-install -m 0755 -vp test/cases/*                                   %{buildroot}%{_libexecdir}/tests/osbuild-composer/
+install -m 0755 -vp test/cases/*.sh                                %{buildroot}%{_libexecdir}/tests/osbuild-composer/
+
+install -m 0755 -vd                                                %{buildroot}%{_libexecdir}/tests/osbuild-composer/api
+install -m 0755 -vp test/cases/api/*.sh                            %{buildroot}%{_libexecdir}/tests/osbuild-composer/api/
+
+install -m 0755 -vd                                                %{buildroot}%{_libexecdir}/tests/osbuild-composer/api/common
+install -m 0755 -vp test/cases/api/common/*.sh                     %{buildroot}%{_libexecdir}/tests/osbuild-composer/api/common/
 
 install -m 0755 -vd                                                %{buildroot}%{_datadir}/tests/osbuild-composer/ansible
 install -m 0644 -vp test/data/ansible/*                            %{buildroot}%{_datadir}/tests/osbuild-composer/ansible/
diff --git a/templates/packer/worker.pkr.hcl b/templates/packer/worker.pkr.hcl
index 449329a44..a85b4afb6 100644
--- a/templates/packer/worker.pkr.hcl
+++ b/templates/packer/worker.pkr.hcl
@@ -28,8 +28,8 @@ build {
   source "amazon-ebs.image_builder" {
     name = "rhel-8-x86_64"
 
-    # Use a static RHEL 8.5 Cloud Access Image.
-    source_ami = "ami-06f1e6f8b3457ae7c"
+    # Use a static RHEL 8.6 Cloud Access Image.
+    source_ami = "ami-03debf3ebf61b20cd"
     ssh_username = "ec2-user"
     instance_type = "c6a.large"
 
diff --git a/tools/appsre-build-worker-packer.sh b/tools/appsre-build-worker-packer.sh
index 41192384b..f1c471403 100755
--- a/tools/appsre-build-worker-packer.sh
+++ b/tools/appsre-build-worker-packer.sh
@@ -79,7 +79,7 @@ mkdir -p "$RPMBUILD_DIR"
 aws ec2 create-key-pair --key-name "$KEY_NAME" --query 'KeyMaterial' --output text > /osbuild-composer/keypair.pem
 chmod 600 /osbuild-composer/keypair.pem
 # rhel 8.5 AMI
-aws ec2 run-instances --image-id ami-06f1e6f8b3457ae7c --instance-type c5.large --key-name "$KEY_NAME" \
+aws ec2 run-instances --image-id ami-03debf3ebf61b20cd --instance-type c5.large --key-name "$KEY_NAME" \
     --tag-specifications "ResourceType=instance,Tags=[{Key=commit,Value=$COMMIT_SHA},{Key=name,Value=rpm-builder-$COMMIT_SHA}]" \
     > ./rpminstance.json
 AWS_INSTANCE_ID=$(jq -r '.Instances[].InstanceId' "rpminstance.json")
@@ -104,7 +104,7 @@ ansible-playbook \
     -i /osbuild-composer/tools/appsre-ansible/inventory \
     /osbuild-composer/tools/appsre-ansible/rpmbuild.yml \
     -e "COMPOSER_COMMIT=$COMMIT_SHA" \
-    -e "OSBUILD_COMMIT=$(jq -r '.["rhel-8.5"].dependencies.osbuild.commit' /osbuild-composer/Schutzfile)" \
+    -e "OSBUILD_COMMIT=$(jq -r '.["rhel-8.6"].dependencies.osbuild.commit' /osbuild-composer/Schutzfile)" \
     -e "RH_ACTIVATION_KEY=$RH_ACTIVATION_KEY" \
     -e "RH_ORG_ID=$RH_ORG_ID"
 EOF
@@ -165,7 +165,7 @@ EOF
         # get distro name for schutzfile
         local schutzfile_distro="$distro"
         if [[ $schutzfile_distro == rhel-8 ]]; then
-            schutzfile_distro=rhel-8.5
+            schutzfile_distro=rhel-8.6
         fi
 
         # get osbuild_commit from schutzfile


Test patch:
diff --git a/test/cases/api.sh b/test/cases/api.sh
index 4d96b7630..ed0e605b8 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -25,15 +25,15 @@ CLOUD_PROVIDER_GENERIC_S3="generic.s3"
 #
 # Supported Image type names
 #
-IMAGE_TYPE_AWS="aws"
-IMAGE_TYPE_AZURE="azure"
-IMAGE_TYPE_EDGE_COMMIT="edge-commit"
-IMAGE_TYPE_EDGE_CONTAINER="edge-container"
-IMAGE_TYPE_EDGE_INSTALLER="edge-installer"
-IMAGE_TYPE_GCP="gcp"
-IMAGE_TYPE_IMAGE_INSTALLER="image-installer"
-IMAGE_TYPE_GUEST="guest-image"
-IMAGE_TYPE_VSPHERE="vsphere"
+export IMAGE_TYPE_AWS="aws"
+export IMAGE_TYPE_AZURE="azure"
+export IMAGE_TYPE_EDGE_COMMIT="edge-commit"
+export IMAGE_TYPE_EDGE_CONTAINER="edge-container"
+export IMAGE_TYPE_EDGE_INSTALLER="edge-installer"
+export IMAGE_TYPE_GCP="gcp"
+export IMAGE_TYPE_IMAGE_INSTALLER="image-installer"
+export IMAGE_TYPE_GUEST="guest-image"
+export IMAGE_TYPE_VSPHERE="vsphere"
 
 if (( $# > 2 )); then
     echo "$0 does not support more than two arguments"
@@ -86,7 +86,7 @@ mkdir -p "${ARTIFACTS}"
 source /usr/libexec/osbuild-composer-test/set-env-variables.sh
 
 # Container image used for cloud provider CLI tools
-CONTAINER_IMAGE_CLOUD_TOOLS="quay.io/osbuild/cloud-tools:latest"
+export CONTAINER_IMAGE_CLOUD_TOOLS="quay.io/osbuild/cloud-tools:latest"
 
 #
 # Provision the software under test.
@@ -151,174 +151,39 @@ sudo systemctl restart osbuild-composer
 
 greenprint "Using Cloud Provider / Target ${CLOUD_PROVIDER} for Image Type ${IMAGE_TYPE}"
 
-#
-# Verify that this script is running in the right environment.
-#
-
-# Check that needed variables are set to access AWS.
-function checkEnvAWS() {
-  printenv AWS_REGION AWS_BUCKET V2_AWS_ACCESS_KEY_ID V2_AWS_SECRET_ACCESS_KEY AWS_API_TEST_SHARE_ACCOUNT > /dev/null
-}
-
-# Check that needed variables are set to access GCP.
-function checkEnvGCP() {
-  printenv GOOGLE_APPLICATION_CREDENTIALS GCP_BUCKET GCP_REGION GCP_API_TEST_SHARE_ACCOUNT > /dev/null
-}
-
-# Check that needed variables are set to access Azure.
-function checkEnvAzure() {
-  printenv AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_RESOURCE_GROUP AZURE_LOCATION V2_AZURE_CLIENT_ID V2_AZURE_CLIENT_SECRET > /dev/null
-}
-
-# Check that needed variables are set to register to RHSM (RHEL only)
-function checkEnvSubscription() {
-  printenv API_TEST_SUBSCRIPTION_ORG_ID API_TEST_SUBSCRIPTION_ACTIVATION_KEY_V2 > /dev/null
-}
-
-function checkEnvVSphere() {
-  printenv GOVMOMI_USERNAME GOVMOMI_PASSWORD GOVMOMI_URL GOVMOMI_CLUSTER GOVC_DATACENTER GOVMOMI_DATASTORE GOVMOMI_FOLDER GOVMOMI_NETWORK  > /dev/null
-}
 
+# Load a correct test runner.
+# Each one must define following methods:
+# - checkEnv
+# - cleanup
+# - createReqFile
+# - installClient
+# - checkUploadStatusOptions
 case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
-    checkEnvAWS
-    [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]] && checkEnvVSphere
+  "$CLOUD_PROVIDER_AWS")
+    source /usr/libexec/tests/osbuild-composer/api/aws.sh
+    ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    source /usr/libexec/tests/osbuild-composer/api/aws.s3.sh
     ;;
+  "$CLOUD_PROVIDER_GENERIC_S3")
+      source /usr/libexec/tests/osbuild-composer/api/generic.s3.sh
+      ;;
   "$CLOUD_PROVIDER_GCP")
-    checkEnvGCP
+    source /usr/libexec/tests/osbuild-composer/api/gcp.sh
     ;;
   "$CLOUD_PROVIDER_AZURE")
-    checkEnvAzure
+    source /usr/libexec/tests/osbuild-composer/api/azure.sh
     ;;
+  *)
+    echo "Unknown cloud provider: ${CLOUD_PROVIDER}"
+    exit 1
 esac
-[[ "$ID" == "rhel" ]] && checkEnvSubscription
-
-#
-# Create a temporary directory and ensure it gets deleted when this script
-# terminates in any way.
-#
-
-function cleanupAWS() {
-  # since this function can be called at any time, ensure that we don't expand unbound variables
-  AWS_CMD="${AWS_CMD:-}"
-  AWS_INSTANCE_ID="${AWS_INSTANCE_ID:-}"
-  AMI_IMAGE_ID="${AMI_IMAGE_ID:-}"
-  AWS_SNAPSHOT_ID="${AWS_SNAPSHOT_ID:-}"
-
-  if [ -n "$AWS_CMD" ]; then
-    $AWS_CMD ec2 terminate-instances --instance-ids "$AWS_INSTANCE_ID"
-    $AWS_CMD ec2 deregister-image --image-id "$AMI_IMAGE_ID"
-    $AWS_CMD ec2 delete-snapshot --snapshot-id "$AWS_SNAPSHOT_ID"
-    $AWS_CMD ec2 delete-key-pair --key-name "key-for-$AMI_IMAGE_ID"
-  fi
-}
-
-function cleanupAWSS3() {
-  local S3_URL
-  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
-
-  # extract filename component from URL
-  local S3_FILENAME
-  S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
-
-  # prepend bucket
-  local S3_URI
-  S3_URI="s3://${AWS_BUCKET}/${S3_FILENAME}"
-
-  # since this function can be called at any time, ensure that we don't expand unbound variables
-  AWS_CMD="${AWS_CMD:-}"
-
-  if [ -n "$AWS_CMD" ]; then
-    $AWS_CMD s3 rm "${S3_URI}"
-  fi
-}
-
-function cleanupGCP() {
-  # since this function can be called at any time, ensure that we don't expand unbound variables
-  GCP_CMD="${GCP_CMD:-}"
-  GCP_IMAGE_NAME="${GCP_IMAGE_NAME:-}"
-  GCP_INSTANCE_NAME="${GCP_INSTANCE_NAME:-}"
-  GCP_ZONE="${GCP_ZONE:-}"
 
-  if [ -n "$GCP_CMD" ]; then
-    $GCP_CMD compute instances delete --zone="$GCP_ZONE" "$GCP_INSTANCE_NAME"
-    $GCP_CMD compute images delete "$GCP_IMAGE_NAME"
-  fi
-}
-
-function cleanupAzure() {
-  # since this function can be called at any time, ensure that we don't expand unbound variables
-  AZURE_CMD="${AZURE_CMD:-}"
-  AZURE_IMAGE_NAME="${AZURE_IMAGE_NAME:-}"
-  AZURE_INSTANCE_NAME="${AZURE_INSTANCE_NAME:-}"
-
-  # do not run clean-up if the image name is not yet defined
-  if [[ -n "$AZURE_CMD" && -n "$AZURE_IMAGE_NAME" ]]; then
-    # Re-get the vm_details in case the VM creation is failed.
-    [ -f "$WORKDIR/vm_details.json" ] || $AZURE_CMD vm show --name "$AZURE_INSTANCE_NAME" --resource-group "$AZURE_RESOURCE_GROUP" --show-details > "$WORKDIR/vm_details.json"
-    # Get all the resources ids
-    VM_ID=$(jq -r '.id' "$WORKDIR"/vm_details.json)
-    OSDISK_ID=$(jq -r '.storageProfile.osDisk.managedDisk.id' "$WORKDIR"/vm_details.json)
-    NIC_ID=$(jq -r '.networkProfile.networkInterfaces[0].id' "$WORKDIR"/vm_details.json)
-    $AZURE_CMD network nic show --ids "$NIC_ID" > "$WORKDIR"/nic_details.json
-    NSG_ID=$(jq -r '.networkSecurityGroup.id' "$WORKDIR"/nic_details.json)
-    PUBLICIP_ID=$(jq -r '.ipConfigurations[0].publicIpAddress.id' "$WORKDIR"/nic_details.json)
-
-    # Delete resources. Some resources must be removed in order:
-    # - Delete VM prior to any other resources
-    # - Delete NIC prior to NSG, public-ip
-    # Left Virtual Network and Storage Account there because other tests in the same resource group will reuse them
-    for id in "$VM_ID" "$OSDISK_ID" "$NIC_ID" "$NSG_ID" "$PUBLICIP_ID"; do
-      echo "Deleting $id..."
-      $AZURE_CMD resource delete --ids "$id"
-    done
-
-    # Delete image after VM deleting.
-    $AZURE_CMD image delete --resource-group "$AZURE_RESOURCE_GROUP" --name "$AZURE_IMAGE_NAME"
-    # find a storage account by its tag
-    AZURE_STORAGE_ACCOUNT=$($AZURE_CMD resource list --tag imageBuilderStorageAccount=location="$AZURE_LOCATION" | jq -r .[0].name)
-    AZURE_CONNECTION_STRING=$($AZURE_CMD storage account show-connection-string --name "$AZURE_STORAGE_ACCOUNT" | jq -r .connectionString)
-    $AZURE_CMD storage blob delete --container-name imagebuilder --name "$AZURE_IMAGE_NAME".vhd --account-name "$AZURE_STORAGE_ACCOUNT" --connection-string "$AZURE_CONNECTION_STRING"
-  fi
-}
-
-function cleanupVSphere() {
-    # since this function can be called at any time, ensure that we don't expand unbound variables
-    GOVC_CMD="${GOVC_CMD:-}"
-    VSPHERE_VM_NAME="${VSPHERE_VM_NAME:-}"
-    VSPHERE_CIDATA_ISO_PATH="${VSPHERE_CIDATA_ISO_PATH:-}"
-
-    greenprint "🧹 Cleaning up the VSphere VM"
-    $GOVC_CMD vm.destroy \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        "${VSPHERE_VM_NAME}"
-
-    greenprint "🧹 Cleaning up the VSphere Datastore"
-    $GOVC_CMD datastore.rm \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -ds="${GOVMOMI_DATASTORE}" \
-        -f \
-        "${VSPHERE_CIDATA_ISO_PATH}"
-
-    $GOVC_CMD datastore.rm \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -ds="${GOVMOMI_DATASTORE}" \
-        -f \
-        "${VSPHERE_VM_NAME}"
-}
-
-function cleanupGenericS3() {
-  MINIO_CONTAINER_NAME="${MINIO_CONTAINER_NAME:-}"
-  if [ -n "${MINIO_CONTAINER_NAME}" ]; then
-    sudo ${CONTAINER_RUNTIME} kill "${MINIO_CONTAINER_NAME}"
-  fi
-}
+# Verify that this script is running in the right environment.
+checkEnv
+# Check that needed variables are set to register to RHSM (RHEL only)
+[[ "$ID" == "rhel" ]] && printenv API_TEST_SUBSCRIPTION_ORG_ID API_TEST_SUBSCRIPTION_ACTIVATION_KEY_V2 > /dev/null
 
 function dump_db() {
   # Disable -x for these commands to avoid printing the whole result and manifest into the log
@@ -335,28 +200,10 @@ function dump_db() {
 
 WORKDIR=$(mktemp -d)
 KILL_PIDS=()
-function cleanup() {
-  greenprint "== Script execution stopped or finished - Cleaning up =="
+function cleanups() {
   set +eu
-  case $CLOUD_PROVIDER in
-    "$CLOUD_PROVIDER_AWS")
-      cleanupAWS
-      ;;
-    "$CLOUD_PROVIDER_AWS_S3")
-      cleanupAWSS3
-      [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]] && cleanupVSphere
-      ;;
-    "$CLOUD_PROVIDER_GCP")
-      cleanupGCP
-      ;;
-    "$CLOUD_PROVIDER_AZURE")
-      cleanupAzure
-      ;;
-    "$CLOUD_PROVIDER_GENERIC_S3")
-      cleanupGenericS3
-      [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]] && cleanupVSphere
-    ;;
-  esac
+
+  cleanup
 
   # dump the DB here to ensure that it gets dumped even if the test fails
   dump_db
@@ -371,14 +218,14 @@ function cleanup() {
   done
   set -eu
 }
-trap cleanup EXIT
+trap cleanups EXIT
 
 # make a dummy rpm and repo to test payload_repositories
 sudo dnf install -y rpm-build createrepo
 DUMMYRPMDIR=$(mktemp -d)
 DUMMYSPECFILE="$DUMMYRPMDIR/dummy.spec"
-PAYLOAD_REPO_PORT="9999"
-PAYLOAD_REPO_URL="http://localhost:9999"
+export PAYLOAD_REPO_PORT="9999"
+export PAYLOAD_REPO_URL="http://localhost:9999"
 pushd "$DUMMYRPMDIR"
 
 cat <<EOF > "$DUMMYSPECFILE"
@@ -410,178 +257,10 @@ KILL_PIDS+=("$!")
 popd
 popd
 
-
 #
 # Install the necessary cloud provider client tools
 #
-
-function installClientAWS() {
-  if ! hash aws; then
-    echo "Using 'awscli' from a container"
-    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_IMAGE_CLOUD_TOOLS}
-
-    AWS_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
-      -e AWS_ACCESS_KEY_ID=${V2_AWS_ACCESS_KEY_ID} \
-      -e AWS_SECRET_ACCESS_KEY=${V2_AWS_SECRET_ACCESS_KEY} \
-      -v ${WORKDIR}:${WORKDIR}:Z \
-      ${CONTAINER_IMAGE_CLOUD_TOOLS} aws --region $AWS_REGION --output json --color on"
-  else
-    echo "Using pre-installed 'aws' from the system"
-    AWS_CMD="aws --region $AWS_REGION --output json --color on"
-  fi
-  $AWS_CMD --version
-}
-
-function installClientGCP() {
-  if ! hash gcloud; then
-    echo "Using 'gcloud' from a container"
-    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_IMAGE_CLOUD_TOOLS}
-
-    # directory mounted to the container, in which gcloud stores the credentials after logging in
-    GCP_CMD_CREDS_DIR="${WORKDIR}/gcloud_credentials"
-    mkdir "${GCP_CMD_CREDS_DIR}"
-
-    GCP_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
-      -v ${GCP_CMD_CREDS_DIR}:/root/.config/gcloud:Z \
-      -v ${GOOGLE_APPLICATION_CREDENTIALS}:${GOOGLE_APPLICATION_CREDENTIALS}:Z \
-      -v ${WORKDIR}:${WORKDIR}:Z \
-      ${CONTAINER_IMAGE_CLOUD_TOOLS} gcloud --format=json"
-  else
-    echo "Using pre-installed 'gcloud' from the system"
-    GCP_CMD="gcloud --format=json --quiet"
-  fi
-  $GCP_CMD --version
-}
-
-function installClientAzure() {
-  if ! hash az; then
-    echo "Using 'azure-cli' from a container"
-    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_IMAGE_CLOUD_TOOLS}
-
-    # directory mounted to the container, in which azure-cli stores the credentials after logging in
-    AZURE_CMD_CREDS_DIR="${WORKDIR}/azure-cli_credentials"
-    mkdir "${AZURE_CMD_CREDS_DIR}"
-
-    AZURE_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
-      -v ${AZURE_CMD_CREDS_DIR}:/root/.azure:Z \
-      -v ${WORKDIR}:${WORKDIR}:Z \
-      ${CONTAINER_IMAGE_CLOUD_TOOLS} az"
-  else
-    echo "Using pre-installed 'azure-cli' from the system"
-    AZURE_CMD="az"
-  fi
-  $AZURE_CMD version
-}
-
-function installClientVSphere() {
-    if ! hash govc; then
-        greenprint "Installing govc"
-        pushd "${WORKDIR}"
-            curl -Ls --retry 5 --output govc.gz \
-                https://github.com/vmware/govmomi/releases/download/v0.24.0/govc_linux_amd64.gz
-            gunzip -f govc.gz
-            GOVC_CMD="${WORKDIR}/govc"
-            chmod +x "${GOVC_CMD}"
-        popd
-    else
-        echo "Using pre-installed 'govc' from the system"
-        GOVC_CMD="govc"
-    fi
-
-    $GOVC_CMD version
-}
-
-function installGenericS3() {
-  local CONTAINER_MINIO_SERVER="quay.io/minio/minio:latest"
-  MINIO_CONTAINER_NAME="minio-server"
-  MINIO_ENDPOINT="http://localhost:9000"
-  local MINIO_ROOT_USER="X29DU5Q6C5NKDQ8PLGVT"
-  local MINIO_ROOT_PASSWORD
-  MINIO_ROOT_PASSWORD=$(date +%s | sha256sum | base64 | head -c 32 ; echo)
-  MINIO_BUCKET="ci-test"
-  local MINIO_REGION="us-east-1"
-  local MINIO_CREDENTIALS_FILE="/etc/osbuild-worker/minio-creds"
-
-  sudo ${CONTAINER_RUNTIME} run --rm -d \
-    --name ${MINIO_CONTAINER_NAME} \
-    -p 9000:9000 \
-    -e MINIO_BROWSER=off \
-    -e MINIO_ROOT_USER="${MINIO_ROOT_USER}" \
-    -e MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}" \
-    ${CONTAINER_MINIO_SERVER} server /data
-
-  if ! hash aws; then
-    echo "Using 'awscli' from a container"
-    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_IMAGE_CLOUD_TOOLS}
-
-    AWS_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
-      -e AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER} \
-      -e AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} \
-      -v ${WORKDIR}:${WORKDIR}:Z \
-      --network host \
-      ${CONTAINER_IMAGE_CLOUD_TOOLS} aws"
-  else
-    echo "Using pre-installed 'aws' from the system"
-    AWS_CMD="AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER} \
-      AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} \
-      aws"
-  fi
-  AWS_CMD+=" --region $MINIO_REGION --output json --color on --endpoint-url $MINIO_ENDPOINT"
-  $AWS_CMD --version
-
-  # Configure the local server (retry until the service is up)
-  MINIO_CONFIGURE_RETRY=0
-  MINIO_CONFIGURE_MAX_RETRY=5
-  MINIO_RETRY_INTERVAL=15
-  until [ "${MINIO_CONFIGURE_RETRY}" -ge "${MINIO_CONFIGURE_MAX_RETRY}" ]
-  do
-      ${AWS_CMD} s3 ls && break
-      MINIO_CONFIGURE_RETRY=$((MINIO_CONFIGURE_RETRY + 1))
-    echo "Retrying [${MINIO_CONFIGURE_RETRY}/${MINIO_CONFIGURE_MAX_RETRY}] in ${MINIO_RETRY_INTERVAL}(s) "
-    sleep ${MINIO_RETRY_INTERVAL}
-  done
-
-  if [ "${MINIO_CONFIGURE_RETRY}" -ge "${MINIO_CONFIGURE_MAX_RETRY}" ]; then
-    echo "Failed to communicate with the MinIO server after ${MINIO_CONFIGURE_MAX_RETRY} attempts!"
-    exit 1
-  fi
-
-  # Create the bucket
-  ${AWS_CMD} s3 mb s3://${MINIO_BUCKET}
-
-  cat <<EOF | sudo tee "${MINIO_CREDENTIALS_FILE}"
-[default]
-aws_access_key_id = ${MINIO_ROOT_USER}
-aws_secret_access_key = ${MINIO_ROOT_PASSWORD}
-EOF
-
-  cat <<EOF | sudo tee "/etc/osbuild-worker/osbuild-worker.toml"
-[generic_s3]
-credentials = "${MINIO_CREDENTIALS_FILE}"
-endpoint = "${MINIO_ENDPOINT}"
-region = "${MINIO_REGION}"
-bucket = "${MINIO_BUCKET}"
-EOF
-
-  sudo systemctl restart "osbuild-worker@1"
-}
-
-case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
-    installClientAWS
-    [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]] && installClientVSphere
-    ;;
-  "$CLOUD_PROVIDER_GCP")
-    installClientGCP
-    ;;
-  "$CLOUD_PROVIDER_AZURE")
-    installClientAzure
-    ;;
-  "$CLOUD_PROVIDER_GENERIC_S3")
-    installGenericS3
-    [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]] && installClientVSphere
-    ;;
-esac
+installClient
 
 #
 # Make sure /openapi and endpoints return success
@@ -602,6 +281,7 @@ curl \
 REQUEST_FILE="${WORKDIR}/request.json"
 ARCH=$(uname -m)
 SSH_USER=
+TEST_ID="$(uuidgen)"
 
 # Generate a string, which can be used as a predictable resource name,
 # especially when running the test in CI where we may need to clean up
@@ -610,10 +290,8 @@ CI="${CI:-false}"
 if [[ "$CI" == true ]]; then
   # in CI, imitate GenerateCIArtifactName() from internal/test/helpers.go
   TEST_ID="$DISTRO_CODE-$ARCH-$CI_COMMIT_BRANCH-$CI_BUILD_ID"
-else
-  # if not running in Jenkins, generate ID not relying on specific env variables
-  TEST_ID=$(uuidgen);
 fi
+export TEST_ID
 
 if [[ "$ID" == "fedora" ]]; then
   # fedora uses fedora for everything
@@ -625,12 +303,14 @@ else
   # RHEL and centos use cloud-user for other clouds
   SSH_USER="cloud-user"
 fi
+export SSH_USER
 
 # This removes dot from VERSION_ID.
 # ID == rhel   && VERSION_ID == 8.6 => DISTRO == rhel-86
 # ID == centos && VERSION_ID == 8   => DISTRO == centos-8
 # ID == fedora && VERSION_ID == 35  => DISTRO == fedora-35
-DISTRO="$ID-${VERSION_ID//./}"
+export DISTRO="$ID-${VERSION_ID//./}"
+SUBSCRIPTION_BLOCK=
 
 # Only RHEL need subscription block.
 if [[ "$ID" == "rhel" ]]; then
@@ -645,247 +325,13 @@ if [[ "$ID" == "rhel" ]]; then
     }
 EndOfMessage
 )
-else
-  SUBSCRIPTION_BLOCK=''
 fi
+export SUBSCRIPTION_BLOCK
 
 # generate a temp key for user tests
 ssh-keygen -t rsa-sha2-512 -f "${WORKDIR}/usertest" -C "usertest" -N ""
 
-function createReqFileAWS() {
-  AWS_SNAPSHOT_NAME=${TEST_ID}
-
-  cat > "$REQUEST_FILE" << EOF
-{
-  "distribution": "$DISTRO",
-  "customizations": {
-    "filesystem": [
-      {
-        "mountpoint": "/var",
-        "min_size": 262144000
-      }
-    ],
-    "payload_repositories": [
-      {
-        "baseurl": "$PAYLOAD_REPO_URL"
-      }
-    ],
-    "packages": [
-      "postgresql",
-      "dummy"
-    ]${SUBSCRIPTION_BLOCK},
-    "users":[
-      {
-        "name": "user1",
-        "groups": ["wheel"],
-        "key": "$(cat "${WORKDIR}/usertest.pub")"
-      },
-      {
-        "name": "user2",
-        "key": "$(cat "${WORKDIR}/usertest.pub")"
-      }
-    ]
-  },
-  "image_request": {
-      "architecture": "$ARCH",
-      "image_type": "${IMAGE_TYPE}",
-      "repositories": $(jq ".\"$ARCH\" | .[] | select((has(\"image_type_tags\") | not) or (.\"image_type_tags\" | index(\"${IMAGE_TYPE}\")))" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json | jq -s .),
-      "upload_options": {
-        "region": "${AWS_REGION}",
-        "snapshot_name": "${AWS_SNAPSHOT_NAME}",
-        "share_with_accounts": ["${AWS_API_TEST_SHARE_ACCOUNT}"]
-    }
-  }
-}
-EOF
-}
-
-#
-# Global var for ostree ref (only used in aws.s3 now)
-#
-OSTREE_REF="test/rhel/8/edge"
-
-function createReqFileS3() {
-  local IMAGE_REQUEST_REGION=${1:-""}
-  cat > "$REQUEST_FILE" << EOF
-{
-  "distribution": "$DISTRO",
-  "customizations": {
-    "payload_repositories": [
-      {
-        "baseurl": "$PAYLOAD_REPO_URL"
-      }
-    ],
-    "packages": [
-      "postgresql",
-      "dummy"
-    ],
-    "users":[
-      {
-        "name": "user1",
-        "groups": ["wheel"],
-        "key": "$(cat "${WORKDIR}/usertest.pub")"
-      },
-      {
-        "name": "user2",
-        "key": "$(cat "${WORKDIR}/usertest.pub")"
-      }
-    ]
-  },
-  "image_request": {
-    "architecture": "$ARCH",
-    "image_type": "${IMAGE_TYPE}",
-    "repositories": $(jq ".\"$ARCH\" | .[] | select((has(\"image_type_tags\") | not) or (.\"image_type_tags\" | index(\"${IMAGE_TYPE}\")))" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json | jq -s .),
-    "ostree": {
-      "ref": "${OSTREE_REF}"
-    },
-    "upload_options": {
-      "region": "${IMAGE_REQUEST_REGION}"
-    }
-  }
-}
-EOF
-}
-
-# the VSphere test case does not create any additional users,
-# since this is not supported by the service UI
-function createReqFileS3VSphere() {
-  local IMAGE_REQUEST_REGION=${1:-""}
-  cat > "$REQUEST_FILE" << EOF
-{
-  "distribution": "$DISTRO",
-  "customizations": {
-    "payload_repositories": [
-      {
-        "baseurl": "$PAYLOAD_REPO_URL"
-      }
-    ],
-    "packages": [
-      "postgresql",
-      "dummy"
-    ]${SUBSCRIPTION_BLOCK}
-  },
-  "image_request": {
-    "architecture": "$ARCH",
-    "image_type": "${IMAGE_TYPE}",
-    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
-    "upload_options": {
-      "region": "${IMAGE_REQUEST_REGION}"
-    }
-  }
-}
-EOF
-}
-
-function createReqFileGCP() {
-  # constrains for GCP resource IDs:
-  # - max 62 characters
-  # - must be a match of regex '[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?|[1-9][0-9]{0,19}'
-  #
-  # use sha224sum to get predictable 56 characters long testID without invalid characters
-  GCP_TEST_ID_HASH="$(echo -n "$TEST_ID" | sha224sum - | sed -E 's/([a-z0-9])\s+-/\1/')"
-
-  GCP_IMAGE_NAME="image-$GCP_TEST_ID_HASH"
-
-  cat > "$REQUEST_FILE" << EOF
-{
-  "distribution": "$DISTRO",
-  "customizations": {
-    "filesystem": [
-      {
-        "mountpoint": "/var",
-        "min_size": 262144000
-      }
-    ],
-    "payload_repositories": [
-      {
-        "baseurl": "$PAYLOAD_REPO_URL"
-      }
-    ],
-    "packages": [
-      "postgresql",
-      "dummy"
-    ]${SUBSCRIPTION_BLOCK}
-  },
-  "image_request": {
-    "architecture": "$ARCH",
-    "image_type": "${IMAGE_TYPE}",
-    "repositories": $(jq ".\"$ARCH\" | .[] | select((has(\"image_type_tags\") | not) or (.\"image_type_tags\" | index(\"${IMAGE_TYPE}\")))" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json | jq -s .),
-    "upload_options": {
-      "bucket": "${GCP_BUCKET}",
-      "region": "${GCP_REGION}",
-      "image_name": "${GCP_IMAGE_NAME}",
-      "share_with_accounts": ["${GCP_API_TEST_SHARE_ACCOUNT}"]
-    }
-  }
-}
-EOF
-}
-
-function createReqFileAzure() {
-  AZURE_IMAGE_NAME="image-$TEST_ID"
-
-  cat > "$REQUEST_FILE" << EOF
-{
-  "distribution": "$DISTRO",
-  "customizations": {
-    "filesystem": [
-      {
-        "mountpoint": "/var",
-        "min_size": 262144000
-      }
-    ],
-    "payload_repositories": [
-      {
-        "baseurl": "$PAYLOAD_REPO_URL"
-      }
-    ],
-    "packages": [
-      "postgresql",
-      "dummy"
-    ]${SUBSCRIPTION_BLOCK}
-  },
-  "image_request": {
-    "architecture": "$ARCH",
-    "image_type": "${IMAGE_TYPE}",
-    "repositories": $(jq ".\"$ARCH\" | .[] | select((has(\"image_type_tags\") | not) or (.\"image_type_tags\" | index(\"${IMAGE_TYPE}\")))" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json | jq -s .),
-    "upload_options": {
-      "tenant_id": "${AZURE_TENANT_ID}",
-      "subscription_id": "${AZURE_SUBSCRIPTION_ID}",
-      "resource_group": "${AZURE_RESOURCE_GROUP}",
-      "location": "${AZURE_LOCATION}",
-      "image_name": "${AZURE_IMAGE_NAME}"
-    }
-  }
-}
-EOF
-}
-
-case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
-    createReqFileAWS
-    ;;
-  "$CLOUD_PROVIDER_AWS_S3")
-    if [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]]; then
-      createReqFileS3VSphere "${AWS_REGION}"
-    else
-      createReqFileS3 "${AWS_REGION}"
-    fi
-    ;;
-  "$CLOUD_PROVIDER_GCP")
-    createReqFileGCP
-    ;;
-  "$CLOUD_PROVIDER_AZURE")
-    createReqFileAzure
-    ;;
-  "$CLOUD_PROVIDER_GENERIC_S3")
-    if [[ "${IMAGE_TYPE}" == "${IMAGE_TYPE_VSPHERE}" ]]; then
-      createReqFileS3VSphere
-    else
-      createReqFileS3
-    fi
-    ;;
-esac
+createReqFile
 
 #
 # Send the request and wait for the job to finish.
@@ -961,6 +407,9 @@ function waitForState() {
 
         sleep 30
     done
+
+    # export for use in subcases
+    export UPLOAD_OPTIONS
 }
 
 #
@@ -972,6 +421,7 @@ jq '.customizations.packages = [ "jesuisunpaquetquinexistepas" ]' "$REQUEST_FILE
 sendCompose "$REQUEST_FILE2"
 waitForState "failure"
 
+
 # crashed/stopped/killed worker should result in a failed state
 sendCompose "$REQUEST_FILE"
 waitForState "building"
@@ -996,634 +446,12 @@ test $((INIT_COMPOSES+1)) = "$SUBS_COMPOSES"
 #
 # Verify the Cloud-provider specific upload_status options
 #
-
-function checkUploadStatusOptionsAWS() {
-  local AMI
-  AMI=$(echo "$UPLOAD_OPTIONS" | jq -r '.ami')
-  local REGION
-  REGION=$(echo "$UPLOAD_OPTIONS" | jq -r '.region')
-
-  # AWS ID consist of resource identifier followed by a 17-character string
-  echo "$AMI" | grep -e 'ami-[[:alnum:]]\{17\}' -
-  test "$REGION" = "$AWS_REGION"
-}
-
-function checkUploadStatusOptionsAWSS3() {
-  local S3_URL
-  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
-
-  # S3 URL contains region and bucket name
-  echo "$S3_URL" | grep -F "$AWS_BUCKET" -
-  echo "$S3_URL" | grep -F "$AWS_REGION" -
-}
-
-function checkUploadStatusOptionsGCP() {
-  GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
-
-  local IMAGE_NAME
-  IMAGE_NAME=$(echo "$UPLOAD_OPTIONS" | jq -r '.image_name')
-  local PROJECT_ID
-  PROJECT_ID=$(echo "$UPLOAD_OPTIONS" | jq -r '.project_id')
-
-  test "$IMAGE_NAME" = "$GCP_IMAGE_NAME"
-  test "$PROJECT_ID" = "$GCP_PROJECT"
-}
-
-function checkUploadStatusOptionsAzure() {
-  local IMAGE_NAME
-  IMAGE_NAME=$(echo "$UPLOAD_OPTIONS" | jq -r '.image_name')
-
-  test "$IMAGE_NAME" = "$AZURE_IMAGE_NAME"
-}
-
-function checkUploadStatusOptionsGenericS3() {
-  local S3_URL
-  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
-
-  # S3 URL contains endpoint and bucket name
-  echo "$S3_URL" | grep -F "$MINIO_ENDPOINT" -
-  echo "$S3_URL" | grep -F "$MINIO_BUCKET" -
-}
-
-case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
-    checkUploadStatusOptionsAWS
-    ;;
-  "$CLOUD_PROVIDER_AWS_S3")
-    checkUploadStatusOptionsAWSS3
-    ;;
-  "$CLOUD_PROVIDER_GCP")
-    checkUploadStatusOptionsGCP
-    ;;
-  "$CLOUD_PROVIDER_AZURE")
-    checkUploadStatusOptionsAzure
-    ;;
-  "$CLOUD_PROVIDER_GENERIC_S3")
-    checkUploadStatusOptionsGenericS3
-    ;;
-esac
+checkUploadStatusOptions
 
 #
 # Verify the image landed in the appropriate cloud provider, and delete it.
 #
-
-# Reusable function, which waits for a given host to respond to SSH
-function _instanceWaitSSH() {
-  local HOST="$1"
-
-  for LOOP_COUNTER in {0..30}; do
-      if ssh-keyscan "$HOST" > /dev/null 2>&1; then
-          echo "SSH is up!"
-          ssh-keyscan "$HOST" | sudo tee -a /root/.ssh/known_hosts
-          break
-      fi
-      echo "Retrying in 5 seconds... $LOOP_COUNTER"
-      sleep 5
-  done
-}
-
-function _instanceCheck() {
-  echo "✔️ Instance checking"
-  local _ssh="$1"
-
-  # Check if postgres is installed
-  $_ssh rpm -q postgresql dummy
-
-  # Verify subscribe status. Loop check since the system may not be registered such early(RHEL only)
-  if [[ "$ID" == "rhel" ]]; then
-    set +eu
-    for LOOP_COUNTER in {1..10}; do
-        subscribe_org_id=$($_ssh sudo subscription-manager identity | grep 'org ID')
-        if [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]; then
-            echo "System is subscribed."
-            break
-        else
-            echo "System is not subscribed. Retrying in 30 seconds...($LOOP_COUNTER/10)"
-            sleep 30
-        fi
-    done
-    set -eu
-    [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]
-
-    # Unregister subscription
-    $_ssh sudo subscription-manager unregister
-  else
-    echo "Not RHEL OS. Skip subscription check."
-  fi
-}
-
-# Create a cloud-int user-data file
-#
-# Returns:
-#   - path to the user-data file
-#
-# Arguments:
-#   $1 - default username
-#   $2 - path to the SSH public key to set as authorized for the user
-function _createCIUserdata() {
-    local _user="$1"
-    local _ssh_pubkey_path="$2"
-
-    local _ci_userdata_dir
-    _ci_userdata_dir="$(mktemp -d -p "${WORKDIR}")"
-    local _ci_userdata_path="${_ci_userdata_dir}/user-data"
-
-    cat > "${_ci_userdata_path}" <<EOF
-#cloud-config
-users:
-    - name: "${_user}"
-      sudo: "ALL=(ALL) NOPASSWD:ALL"
-      ssh_authorized_keys:
-          - "$(cat "${_ssh_pubkey_path}")"
-EOF
-
-    echo "${_ci_userdata_path}"
-}
-
-# Create a cloud-int meta-data file
-#
-# Returns:
-#   - path to the meta-data file
-#
-# Arguments:
-#   $1 - VM name
-function _createCIMetadata() {
-    local _vm_name="$1"
-
-    local _ci_metadata_dir
-    _ci_metadata_dir="$(mktemp -d -p "${WORKDIR}")"
-    local _ci_metadata_path="${_ci_metadata_dir}/meta-data"
-
-    cat > "${_ci_metadata_path}" <<EOF
-instance-id: ${_vm_name}
-local-hostname: ${_vm_name}
-EOF
-
-    echo "${_ci_metadata_path}"
-}
-
-# Create an ISO with the provided cloud-init user-data file
-#
-# Returns:
-#   - path to the created ISO file
-#
-# Arguments:
-#   $1 - path to the cloud-init user-data file
-#   $2 - path to the cloud-init meta-data file
-function _createCIUserdataISO() {
-    local _ci_userdata_path="$1"
-    local _ci_metadata_path="$2"
-
-    local _iso_path
-    _iso_path="$(mktemp -p "${WORKDIR}" --suffix .iso)"
-    mkisofs \
-      -input-charset "utf-8" \
-      -output "${_iso_path}" \
-      -volid "cidata" \
-      -joliet \
-      -rock \
-      -quiet \
-      -graft-points \
-      "${_ci_userdata_path}" \
-      "${_ci_metadata_path}"
-
-    echo "${_iso_path}"
-}
-
-# Verify image in EC2 on AWS
-function verifyInAWS() {
-  $AWS_CMD ec2 describe-images \
-    --owners self \
-    --filters Name=name,Values="$AWS_SNAPSHOT_NAME" \
-    > "$WORKDIR/ami.json"
-
-  AMI_IMAGE_ID=$(jq -r '.Images[].ImageId' "$WORKDIR/ami.json")
-  AWS_SNAPSHOT_ID=$(jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId' "$WORKDIR/ami.json")
-
-  # Tag image and snapshot with "gitlab-ci-test" tag
-  $AWS_CMD ec2 create-tags \
-    --resources "${AWS_SNAPSHOT_ID}" "${AMI_IMAGE_ID}" \
-    --tags Key=gitlab-ci-test,Value=true
-
-
-  SHARE_OK=1
-
-  # Verify that the ec2 snapshot was shared
-  $AWS_CMD ec2 describe-snapshot-attribute --snapshot-id "$AWS_SNAPSHOT_ID" --attribute createVolumePermission > "$WORKDIR/snapshot-attributes.json"
-
-  SHARED_ID=$(jq -r '.CreateVolumePermissions[0].UserId' "$WORKDIR/snapshot-attributes.json")
-  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
-    SHARE_OK=0
-  fi
-
-  # Verify that the ec2 ami was shared
-  $AWS_CMD ec2 describe-image-attribute --image-id "$AMI_IMAGE_ID" --attribute launchPermission > "$WORKDIR/ami-attributes.json"
-
-  SHARED_ID=$(jq -r '.LaunchPermissions[0].UserId' "$WORKDIR/ami-attributes.json")
-  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
-    SHARE_OK=0
-  fi
-
-  if [ "$SHARE_OK" != 1 ]; then
-    echo "EC2 snapshot wasn't shared with the AWS_API_TEST_SHARE_ACCOUNT. 😢"
-    exit 1
-  fi
-
-  # Create key-pair
-  $AWS_CMD ec2 create-key-pair --key-name "key-for-$AMI_IMAGE_ID" --query 'KeyMaterial' --output text > keypair.pem
-  chmod 400 ./keypair.pem
-
-  # Create an instance based on the ami
-  $AWS_CMD ec2 run-instances --image-id "$AMI_IMAGE_ID" --count 1 --instance-type t2.micro --key-name "key-for-$AMI_IMAGE_ID" --tag-specifications 'ResourceType=instance,Tags=[{Key=gitlab-ci-test,Value=true}]' > "$WORKDIR/instances.json"
-  AWS_INSTANCE_ID=$(jq -r '.Instances[].InstanceId' "$WORKDIR/instances.json")
-
-  $AWS_CMD ec2 wait instance-running --instance-ids "$AWS_INSTANCE_ID"
-
-  $AWS_CMD ec2 describe-instances --instance-ids "$AWS_INSTANCE_ID" > "$WORKDIR/instances.json"
-  HOST=$(jq -r '.Reservations[].Instances[].PublicIpAddress' "$WORKDIR/instances.json")
-
-  echo "⏱ Waiting for AWS instance to respond to ssh"
-  _instanceWaitSSH "$HOST"
-
-  # Verify image
-  _ssh="ssh -oStrictHostKeyChecking=no -i ./keypair.pem $SSH_USER@$HOST"
-  _instanceCheck "$_ssh"
-
-  # Check access to user1 and user2
-  check_groups=$(ssh -oStrictHostKeyChecking=no -i "${WORKDIR}/usertest" "user1@$HOST" -t 'groups')
-  if [[ $check_groups =~ "wheel" ]]; then
-   echo "✔️  user1 has the group wheel"
-  else
-    echo 'user1 should have the group wheel 😢'
-    exit 1
-  fi
-  check_groups=$(ssh -oStrictHostKeyChecking=no -i "${WORKDIR}/usertest" "user2@$HOST" -t 'groups')
-  if [[ $check_groups =~ "wheel" ]]; then
-    echo 'user2 should not have group wheel 😢'
-    exit 1
-  else
-   echo "✔️  user2 does not have the group wheel"
-  fi
-}
-
-# verify edge commit content
-function verifyEdgeCommit() {
-    filename="$1"
-    greenprint "Verifying contents of ${filename}"
-
-    # extract tarball and save file list to artifacts directroy
-    local COMMIT_DIR
-    COMMIT_DIR="${WORKDIR}/edge-commit"
-    mkdir -p "${COMMIT_DIR}"
-    tar xvf "${filename}" -C "${COMMIT_DIR}" > "${ARTIFACTS}/edge-commit-filelist.txt"
-
-    # Verify that the commit contains the ref we defined in the request
-    sudo dnf install -y ostree
-    local COMMIT_REF
-    COMMIT_REF=$(ostree refs --repo "${COMMIT_DIR}/repo")
-    if [[ "${COMMIT_REF}" !=  "${OSTREE_REF}" ]]; then
-        echo "Commit ref in archive does not match request 😠"
-        exit 1
-    fi
-
-    local TAR_COMMIT_ID
-    TAR_COMMIT_ID=$(ostree rev-parse --repo "${COMMIT_DIR}/repo" "${OSTREE_REF}")
-    API_COMMIT_ID_V2=$(curl \
-        --silent \
-        --show-error \
-        --cacert /etc/osbuild-composer/ca-crt.pem \
-        --key /etc/osbuild-composer/client-key.pem \
-        --cert /etc/osbuild-composer/client-crt.pem \
-        https://localhost/api/image-builder-composer/v2/composes/"$COMPOSE_ID"/metadata | jq -r '.ostree_commit')
-
-    if [[ "${API_COMMIT_ID_V2}" != "${TAR_COMMIT_ID}" ]]; then
-        echo "Commit ID returned from API does not match Commit ID in archive 😠"
-        exit 1
-    fi
-
-}
-
-# Verify image blobs from s3
-function verifyDisk() {
-    filename="$1"
-    greenprint "Verifying contents of ${filename}"
-
-    infofile="${filename}-info.json"
-    sudo /usr/libexec/osbuild-composer-test/image-info "${filename}" | tee "${infofile}" > /dev/null
-
-    # save image info to artifacts
-    cp -v "${infofile}" "${ARTIFACTS}/image-info.json"
-
-    # check compose request users in passwd
-    if ! jq .passwd "${infofile}" | grep -q "user1"; then
-        greenprint "❌ user1 not found in passwd file"
-        exit 1
-    fi
-    if ! jq .passwd "${infofile}" | grep -q "user2"; then
-        greenprint "❌ user2 not found in passwd file"
-        exit 1
-    fi
-    # check packages for postgresql
-    if ! jq .packages "${infofile}" | grep -q "postgresql"; then
-        greenprint "❌ postgresql not found in packages"
-        exit 1
-    fi
-
-    greenprint "✅ ${filename} image info verified"
-}
-
-# Verify VMDK image in VSphere
-function verifyInVSphere() {
-    local _filename="$1"
-    greenprint "Verifying VMDK image: ${_filename}"
-
-    # Create SSH keys to use
-    local _vsphere_ssh_key="${WORKDIR}/vsphere_ssh_key"
-    ssh-keygen -t rsa-sha2-512 -f "${_vsphere_ssh_key}" -C "${SSH_USER}" -N ""
-
-    VSPHERE_VM_NAME="osbuild-composer-vm-${TEST_ID}"
-
-    # create cloud-init ISO with the configuration
-    local _ci_userdata_path
-    _ci_userdata_path="$(_createCIUserdata "${SSH_USER}" "${_vsphere_ssh_key}.pub")"
-    local _ci_metadata_path
-    _ci_metadata_path="$(_createCIMetadata "${VSPHERE_VM_NAME}")"
-    greenprint "💿 Creating cloud-init user-data ISO"
-    local _ci_iso_path
-    _ci_iso_path="$(_createCIUserdataISO "${_ci_userdata_path}" "${_ci_metadata_path}")"
-
-    VSPHERE_IMAGE_NAME="${VSPHERE_VM_NAME}.vmdk"
-    mv "${_filename}" "${WORKDIR}/${VSPHERE_IMAGE_NAME}"
-
-    # import the built VMDK image to VSphere
-    # import.vmdk seems to be creating the provided directory and
-    # if one with this name exists, it appends "_<number>" to the name
-    greenprint "💿 ⬆️ Importing the converted VMDK image to VSphere"
-    $GOVC_CMD import.vmdk \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -ds="${GOVMOMI_DATASTORE}" \
-        "${WORKDIR}/${VSPHERE_IMAGE_NAME}" \
-        "${VSPHERE_VM_NAME}"
-
-    # create the VM, but don't start it
-    greenprint "🖥️ Creating VM in VSphere"
-    $GOVC_CMD vm.create \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -pool="${GOVMOMI_CLUSTER}"/Resources \
-        -ds="${GOVMOMI_DATASTORE}" \
-        -folder="${GOVMOMI_FOLDER}" \
-        -net="${GOVMOMI_NETWORK}" \
-        -net.adapter=vmxnet3 \
-        -m=4096 -c=2 -g=rhel8_64Guest -on=true -firmware=bios \
-        -disk="${VSPHERE_VM_NAME}/${VSPHERE_IMAGE_NAME}" \
-        -disk.controller=ide \
-        -on=false \
-        "${VSPHERE_VM_NAME}"
-
-    # upload ISO, create CDROM device and insert the ISO in it
-    greenprint "💿 ⬆️ Uploading the cloud-init user-data ISO to VSphere"
-    VSPHERE_CIDATA_ISO_PATH="${VSPHERE_VM_NAME}/cidata.iso"
-    $GOVC_CMD datastore.upload \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -ds="${GOVMOMI_DATASTORE}" \
-        "${_ci_iso_path}" \
-        "${VSPHERE_CIDATA_ISO_PATH}"
-
-    local _cdrom_device
-    greenprint "🖥️ + 💿 Adding a CD-ROM device to the VM"
-    _cdrom_device="$($GOVC_CMD device.cdrom.add \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -vm "${VSPHERE_VM_NAME}")"
-
-    greenprint "💿 Inserting the cloud-init ISO into the CD-ROM device"
-    $GOVC_CMD device.cdrom.insert \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -ds="${GOVMOMI_DATASTORE}" \
-        -vm "${VSPHERE_VM_NAME}" \
-        -device "${_cdrom_device}" \
-        "${VSPHERE_CIDATA_ISO_PATH}"
-
-    # start the VM
-    greenprint "🔌 Powering up the VSphere VM"
-    $GOVC_CMD vm.power \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        -on "${VSPHERE_VM_NAME}"
-
-    HOST=$($GOVC_CMD vm.ip \
-        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
-        -k=true \
-        -dc="${GOVC_DATACENTER}" \
-        "${VSPHERE_VM_NAME}")
-    greenprint "⏱ Waiting for the VSphere VM to respond to ssh"
-    _instanceWaitSSH "${HOST}"
-
-    _ssh="ssh -oStrictHostKeyChecking=no -i ${_vsphere_ssh_key} $SSH_USER@$HOST"
-    _instanceCheck "${_ssh}"
-
-    greenprint "✅ Successfully verified VSphere image with cloud-init"
-}
-
-# Verify s3 blobs
-function verifyInS3() {
-    local BUCKET_NAME=${1}
-    local S3_URL
-    S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
-    greenprint "Verifying S3 object at ${S3_URL}"
-
-    # Tag the resource as a test file
-    local S3_FILENAME
-    S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
-
-    # tag the object, also verifying that it exists in the bucket as expected
-    $AWS_CMD s3api put-object-tagging \
-        --bucket "${BUCKET_NAME}" \
-        --key "${S3_FILENAME}" \
-        --tagging '{"TagSet": [{ "Key": "gitlab-ci-test", "Value": "true" }]}'
-
-    greenprint "✅ Successfully tagged S3 object"
-
-    # Download the object using the Presigned URL and inspect
-    case ${IMAGE_TYPE} in
-        "$IMAGE_TYPE_EDGE_COMMIT")
-            curl "${S3_URL}" --output "${WORKDIR}/edge-commit.tar"
-            verifyEdgeCommit "${WORKDIR}/edge-commit.tar"
-            ;;
-        "${IMAGE_TYPE_GUEST}")
-            curl "${S3_URL}" --output "${WORKDIR}/disk.qcow2"
-            verifyDisk "${WORKDIR}/disk.qcow2"
-            ;;
-
-        "${IMAGE_TYPE_VSPHERE}")
-            curl "${S3_URL}" --output "${WORKDIR}/disk.vmdk"
-            verifyInVSphere "${WORKDIR}/disk.vmdk"
-            ;;
-        *)
-            greenprint "No validation method for image type ${IMAGE_TYPE}"
-            ;;
-    esac
-
-    greenprint "✅ Successfully verified S3 object"
-}
-
-# Verify image in Compute Engine on GCP
-function verifyInGCP() {
-  # Authenticate
-  $GCP_CMD auth activate-service-account --key-file "$GOOGLE_APPLICATION_CREDENTIALS"
-  # Extract and set the default project to be used for commands
-  GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
-  $GCP_CMD config set project "$GCP_PROJECT"
-
-  # Add "gitlab-ci-test" label to the image
-  $GCP_CMD compute images add-labels "$GCP_IMAGE_NAME" --labels=gitlab-ci-test=true
-
-  # Verify that the image was shared
-  SHARE_OK=1
-  $GCP_CMD compute images get-iam-policy "$GCP_IMAGE_NAME" > "$WORKDIR/image-iam-policy.json"
-  SHARED_ACCOUNT=$(jq -r '.bindings[0].members[0]' "$WORKDIR/image-iam-policy.json")
-  SHARED_ROLE=$(jq -r '.bindings[0].role' "$WORKDIR/image-iam-policy.json")
-  if [ "$SHARED_ACCOUNT" != "$GCP_API_TEST_SHARE_ACCOUNT" ] || [ "$SHARED_ROLE" != "roles/compute.imageUser" ]; then
-    SHARE_OK=0
-  fi
-
-  if [ "$SHARE_OK" != 1 ]; then
-    echo "GCP image wasn't shared with the GCP_API_TEST_SHARE_ACCOUNT. 😢"
-    exit 1
-  fi
-
-  # Verify that the image boots and have customizations applied
-  # Create SSH keys to use
-  GCP_SSH_KEY="$WORKDIR/id_google_compute_engine"
-  ssh-keygen -t rsa-sha2-512 -f "$GCP_SSH_KEY" -C "$SSH_USER" -N ""
-
-  # create the instance
-  # resource ID can have max 62 characters, the $GCP_TEST_ID_HASH contains 56 characters
-  GCP_INSTANCE_NAME="vm-$GCP_TEST_ID_HASH"
-
-  # Ensure that we use random GCP region with available 'IN_USE_ADDRESSES' quota
-  # We use the CI variable "GCP_REGION" as the base for expression to filter regions.
-  # It works best if the "GCP_REGION" is set to a storage multi-region, such as "us"
-  local GCP_COMPUTE_REGION
-  GCP_COMPUTE_REGION=$($GCP_CMD compute regions list --filter="name:$GCP_REGION* AND status=UP" | jq -r '.[] | select(.quotas[] as $quota | $quota.metric == "IN_USE_ADDRESSES" and $quota.limit > $quota.usage) | .name' | shuf -n1)
-
-  # Randomize the used GCP zone to prevent hitting "exhausted resources" error on each test re-run
-  GCP_ZONE=$($GCP_CMD compute zones list --filter="region=$GCP_COMPUTE_REGION AND status=UP" | jq -r '.[].name' | shuf -n1)
-
-  # Pick the smallest '^n\d-standard-\d$' machine type from those available in the zone
-  local GCP_MACHINE_TYPE
-  GCP_MACHINE_TYPE=$($GCP_CMD compute machine-types list --filter="zone=$GCP_ZONE AND name~^n\d-standard-\d$" | jq -r '.[].name' | sort | head -1)
-
-  $GCP_CMD compute instances create "$GCP_INSTANCE_NAME" \
-    --zone="$GCP_ZONE" \
-    --image-project="$GCP_PROJECT" \
-    --image="$GCP_IMAGE_NAME" \
-    --machine-type="$GCP_MACHINE_TYPE" \
-    --labels=gitlab-ci-test=true
-
-  HOST=$($GCP_CMD compute instances describe "$GCP_INSTANCE_NAME" --zone="$GCP_ZONE" --format='get(networkInterfaces[0].accessConfigs[0].natIP)')
-
-  echo "⏱ Waiting for GCP instance to respond to ssh"
-  _instanceWaitSSH "$HOST"
-
-  # Verify image
-  _ssh="$GCP_CMD compute ssh --strict-host-key-checking=no --ssh-key-file=$GCP_SSH_KEY --zone=$GCP_ZONE --quiet $SSH_USER@$GCP_INSTANCE_NAME --"
-  _instanceCheck "$_ssh"
-}
-
-# Verify image in Azure
-function verifyInAzure() {
-  set +x
-  $AZURE_CMD login --service-principal --username "${V2_AZURE_CLIENT_ID}" --password "${V2_AZURE_CLIENT_SECRET}" --tenant "${AZURE_TENANT_ID}"
-  set -x
-
-  # verify that the image exists and tag it
-  $AZURE_CMD image show --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}"
-  $AZURE_CMD image update --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}" --tags gitlab-ci-test=true
-
-  # Verify that the image boots and have customizations applied
-  # Create SSH keys to use
-  AZURE_SSH_KEY="$WORKDIR/id_azure"
-  ssh-keygen -t rsa-sha2-512 -f "$AZURE_SSH_KEY" -C "$SSH_USER" -N ""
-
-  # Create network resources with predictable names and tag
-  $AZURE_CMD network nsg create --resource-group "$AZURE_RESOURCE_GROUP" --name "nsg-$TEST_ID" --location "$AZURE_LOCATION" --tags gitlab-ci-test=true
-  $AZURE_CMD network nsg rule create --resource-group "$AZURE_RESOURCE_GROUP" \
-      --nsg-name "nsg-$TEST_ID" \
-      --name SSH \
-      --priority 1001 \
-      --access Allow \
-      --protocol Tcp \
-      --destination-address-prefixes '*' \
-      --destination-port-ranges 22 \
-      --source-port-ranges '*' \
-      --source-address-prefixes '*'
-  $AZURE_CMD network vnet create --resource-group "$AZURE_RESOURCE_GROUP" \
-      --name "vnet-$TEST_ID" \
-      --subnet-name "snet-$TEST_ID" \
-      --location "$AZURE_LOCATION" \
-      --tags gitlab-ci-test=true
-  $AZURE_CMD network public-ip create --resource-group "$AZURE_RESOURCE_GROUP" --name "ip-$TEST_ID" --location "$AZURE_LOCATION" --tags gitlab-ci-test=true
-  $AZURE_CMD network nic create --resource-group "$AZURE_RESOURCE_GROUP" \
-      --name "iface-$TEST_ID" \
-      --subnet "snet-$TEST_ID" \
-      --vnet-name "vnet-$TEST_ID" \
-      --network-security-group "nsg-$TEST_ID" \
-      --public-ip-address "ip-$TEST_ID" \
-      --location "$AZURE_LOCATION" \
-      --tags gitlab-ci-test=true
-
-  # create the instance
-  AZURE_INSTANCE_NAME="vm-$TEST_ID"
-  $AZURE_CMD vm create --name "$AZURE_INSTANCE_NAME" \
-    --resource-group "$AZURE_RESOURCE_GROUP" \
-    --image "$AZURE_IMAGE_NAME" \
-    --size "Standard_B1s" \
-    --admin-username "$SSH_USER" \
-    --ssh-key-values "$AZURE_SSH_KEY.pub" \
-    --authentication-type "ssh" \
-    --location "$AZURE_LOCATION" \
-    --nics "iface-$TEST_ID" \
-    --os-disk-name "disk-$TEST_ID" \
-    --tags gitlab-ci-test=true
-  $AZURE_CMD vm show --name "$AZURE_INSTANCE_NAME" --resource-group "$AZURE_RESOURCE_GROUP" --show-details > "$WORKDIR/vm_details.json"
-  HOST=$(jq -r '.publicIps' "$WORKDIR/vm_details.json")
-
-  echo "⏱  Waiting for Azure instance to respond to ssh"
-  _instanceWaitSSH "$HOST"
-
-  # Verify image
-  _ssh="ssh -oStrictHostKeyChecking=no -i $AZURE_SSH_KEY $SSH_USER@$HOST"
-  _instanceCheck "$_ssh"
-}
-
-case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
-    verifyInAWS
-    ;;
-  "$CLOUD_PROVIDER_AWS_S3")
-    verifyInS3 "${AWS_BUCKET}"
-    ;;
-  "$CLOUD_PROVIDER_GCP")
-    verifyInGCP
-    ;;
-  "$CLOUD_PROVIDER_AZURE")
-    verifyInAzure
-    ;;
-  "$CLOUD_PROVIDER_GENERIC_S3")
-    verifyInS3 "${MINIO_BUCKET}"
-    ;;
-esac
+verify
 
 # Verify selected package (postgresql) is included in package list
 function verifyPackageList() {
diff --git a/test/cases/api/aws.s3.sh b/test/cases/api/aws.s3.sh
new file mode 100644
index 000000000..bb8205656
--- /dev/null
+++ b/test/cases/api/aws.s3.sh
@@ -0,0 +1,172 @@
+#!/usr/bin/bash
+
+source /usr/libexec/tests/osbuild-composer/api/common/aws.sh
+source /usr/libexec/tests/osbuild-composer/api/common/common.sh
+source /usr/libexec/tests/osbuild-composer/api/common/s3.sh
+
+# Check that needed variables are set to access AWS.
+function checkEnv() {
+    printenv AWS_REGION AWS_BUCKET V2_AWS_ACCESS_KEY_ID V2_AWS_SECRET_ACCESS_KEY AWS_API_TEST_SHARE_ACCOUNT > /dev/null
+
+    if [ "${IMAGE_TYPE}" = "${IMAGE_TYPE_VSPHERE}" ]; then
+        printenv GOVMOMI_USERNAME GOVMOMI_PASSWORD GOVMOMI_URL GOVMOMI_CLUSTER GOVC_DATACENTER GOVMOMI_DATASTORE GOVMOMI_FOLDER GOVMOMI_NETWORK  > /dev/null
+    fi
+}
+
+# Global var for ostree ref
+OSTREE_REF="test/rhel/8/edge"
+
+function cleanup() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # extract filename component from URL
+  local S3_FILENAME
+  S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
+
+  # prepend bucket
+  local S3_URI
+  S3_URI="s3://${AWS_BUCKET}/${S3_FILENAME}"
+
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AWS_CMD="${AWS_CMD:-}"
+
+  if [ -n "$AWS_CMD" ]; then
+    $AWS_CMD s3 rm "${S3_URI}"
+  fi
+}
+
+function createReqFile() {
+    case ${IMAGE_TYPE} in
+        "$IMAGE_TYPE_EDGE_COMMIT"|"$IMAGE_TYPE_EDGE_CONTAINER"|"$IMAGE_TYPE_EDGE_INSTALLER"|"$IMAGE_TYPE_IMAGE_INSTALLER")
+            createReqFileEdge
+            ;;
+        "$IMAGE_TYPE_VSPHERE")
+            createReqFileGuest
+            ;;
+        "$IMAGE_TYPE_VSPHERE")
+            createReqFileVSphere
+            ;;
+        *)
+            echo "Unknown s3 image type for: ${IMAGE_TYPE}"
+            exit 1
+    esac
+}
+
+function checkUploadStatusOptions() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # S3 URL contains region and bucket name
+  echo "$S3_URL" | grep -F "$AWS_BUCKET" -
+  echo "$S3_URL" | grep -F "$AWS_REGION" -
+}
+
+
+# verify edge commit content
+function verifyEdgeCommit() {
+    filename="$1"
+    greenprint "Verifying contents of ${filename}"
+
+    # extract tarball and save file list to artifacts directroy
+    local COMMIT_DIR
+    COMMIT_DIR="${WORKDIR}/edge-commit"
+    mkdir -p "${COMMIT_DIR}"
+    tar xvf "${filename}" -C "${COMMIT_DIR}" > "${ARTIFACTS}/edge-commit-filelist.txt"
+
+    # Verify that the commit contains the ref we defined in the request
+    sudo dnf install -y ostree
+    local COMMIT_REF
+    COMMIT_REF=$(ostree refs --repo "${COMMIT_DIR}/repo")
+    if [[ "${COMMIT_REF}" !=  "${OSTREE_REF}" ]]; then
+        echo "Commit ref in archive does not match request 😠"
+        exit 1
+    fi
+
+    local TAR_COMMIT_ID
+    TAR_COMMIT_ID=$(ostree rev-parse --repo "${COMMIT_DIR}/repo" "${OSTREE_REF}")
+    API_COMMIT_ID_V2=$(curl \
+        --silent \
+        --show-error \
+        --cacert /etc/osbuild-composer/ca-crt.pem \
+        --key /etc/osbuild-composer/client-key.pem \
+        --cert /etc/osbuild-composer/client-crt.pem \
+        https://localhost/api/image-builder-composer/v2/composes/"$COMPOSE_ID"/metadata | jq -r '.ostree_commit')
+
+    if [[ "${API_COMMIT_ID_V2}" != "${TAR_COMMIT_ID}" ]]; then
+        echo "Commit ID returned from API does not match Commit ID in archive 😠"
+        exit 1
+    fi
+
+}
+
+# Verify image blobs from s3
+function verifyDisk() {
+    filename="$1"
+    greenprint "Verifying contents of ${filename}"
+
+    infofile="${filename}-info.json"
+    sudo /usr/libexec/osbuild-composer-test/image-info "${filename}" | tee "${infofile}" > /dev/null
+
+    # save image info to artifacts
+    cp -v "${infofile}" "${ARTIFACTS}/image-info.json"
+
+    # check compose request users in passwd
+    if ! jq .passwd "${infofile}" | grep -q "user1"; then
+        greenprint "❌ user1 not found in passwd file"
+        exit 1
+    fi
+    if ! jq .passwd "${infofile}" | grep -q "user2"; then
+        greenprint "❌ user2 not found in passwd file"
+        exit 1
+    fi
+    # check packages for postgresql
+    if ! jq .packages "${infofile}" | grep -q "postgresql"; then
+        greenprint "❌ postgresql not found in packages"
+        exit 1
+    fi
+
+    greenprint "✅ ${filename} image info verified"
+}
+
+
+# Verify s3 blobs
+function verify() {
+    local S3_URL
+    S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+    greenprint "Verifying S3 object at ${S3_URL}"
+
+    # Tag the resource as a test file
+    local S3_FILENAME
+    S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
+
+    # tag the object, also verifying that it exists in the bucket as expected
+    $AWS_CMD s3api put-object-tagging \
+        --bucket "${AWS_BUCKET}" \
+        --key "${S3_FILENAME}" \
+        --tagging '{"TagSet": [{ "Key": "gitlab-ci-test", "Value": "true" }]}'
+
+    greenprint "✅ Successfully tagged S3 object"
+
+    # Download the object using the Presigned URL and inspect
+    case ${IMAGE_TYPE} in
+        "$IMAGE_TYPE_EDGE_COMMIT")
+            curl "${S3_URL}" --output "${WORKDIR}/edge-commit.tar"
+            verifyEdgeCommit "${WORKDIR}/edge-commit.tar"
+            ;;
+        "${IMAGE_TYPE_GUEST}")
+            curl "${S3_URL}" --output "${WORKDIR}/disk.qcow2"
+            verifyDisk "${WORKDIR}/disk.qcow2"
+            ;;
+
+        "${IMAGE_TYPE_VSPHERE}")
+            curl "${S3_URL}" --output "${WORKDIR}/disk.vmdk"
+            verifyInVSphere "${WORKDIR}/disk.vmdk"
+            ;;
+        *)
+            greenprint "No validation method for image type ${IMAGE_TYPE}"
+            ;;
+    esac
+
+    greenprint "✅ Successfully verified S3 object"
+}
diff --git a/test/cases/api/aws.sh b/test/cases/api/aws.sh
new file mode 100644
index 000000000..a8786c811
--- /dev/null
+++ b/test/cases/api/aws.sh
@@ -0,0 +1,160 @@
+#!/usr/bin/bash
+
+source /usr/libexec/tests/osbuild-composer/api/common/aws.sh
+source /usr/libexec/tests/osbuild-composer/api/common/common.sh
+
+function checkEnv() {
+  printenv AWS_REGION AWS_BUCKET V2_AWS_ACCESS_KEY_ID V2_AWS_SECRET_ACCESS_KEY AWS_API_TEST_SHARE_ACCOUNT > /dev/null
+}
+
+function cleanup() {
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AWS_CMD="${AWS_CMD:-}"
+  AWS_INSTANCE_ID="${AWS_INSTANCE_ID:-}"
+  AMI_IMAGE_ID="${AMI_IMAGE_ID:-}"
+  AWS_SNAPSHOT_ID="${AWS_SNAPSHOT_ID:-}"
+
+  if [ -n "$AWS_CMD" ]; then
+    $AWS_CMD ec2 terminate-instances --instance-ids "$AWS_INSTANCE_ID"
+    $AWS_CMD ec2 deregister-image --image-id "$AMI_IMAGE_ID"
+    $AWS_CMD ec2 delete-snapshot --snapshot-id "$AWS_SNAPSHOT_ID"
+    $AWS_CMD ec2 delete-key-pair --key-name "key-for-$AMI_IMAGE_ID"
+  fi
+}
+
+
+function createReqFile() {
+  AWS_SNAPSHOT_NAME=${TEST_ID}
+
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "filesystem": [
+      {
+        "mountpoint": "/var",
+        "min_size": 262144000
+      }
+    ],
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ]${SUBSCRIPTION_BLOCK},
+    "users":[
+      {
+        "name": "user1",
+        "groups": ["wheel"],
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      },
+      {
+        "name": "user2",
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      }
+    ]
+  },
+  "image_request": {
+      "architecture": "$ARCH",
+      "image_type": "${IMAGE_TYPE}",
+      "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+      "upload_options": {
+        "region": "${AWS_REGION}",
+        "snapshot_name": "${AWS_SNAPSHOT_NAME}",
+        "share_with_accounts": ["${AWS_API_TEST_SHARE_ACCOUNT}"]
+    }
+  }
+}
+EOF
+}
+
+
+function checkUploadStatusOptions() {
+  local AMI
+  AMI=$(echo "$UPLOAD_OPTIONS" | jq -r '.ami')
+  local REGION
+  REGION=$(echo "$UPLOAD_OPTIONS" | jq -r '.region')
+
+  # AWS ID consist of resource identifier followed by a 17-character string
+  echo "$AMI" | grep -e 'ami-[[:alnum:]]\{17\}' -
+  test "$REGION" = "$AWS_REGION"
+}
+
+# Verify image in EC2 on AWS
+function verify() {
+  $AWS_CMD ec2 describe-images \
+    --owners self \
+    --filters Name=name,Values="$AWS_SNAPSHOT_NAME" \
+    > "$WORKDIR/ami.json"
+
+  AMI_IMAGE_ID=$(jq -r '.Images[].ImageId' "$WORKDIR/ami.json")
+  AWS_SNAPSHOT_ID=$(jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId' "$WORKDIR/ami.json")
+
+  # Tag image and snapshot with "gitlab-ci-test" tag
+  $AWS_CMD ec2 create-tags \
+    --resources "${AWS_SNAPSHOT_ID}" "${AMI_IMAGE_ID}" \
+    --tags Key=gitlab-ci-test,Value=true
+
+
+  SHARE_OK=1
+
+  # Verify that the ec2 snapshot was shared
+  $AWS_CMD ec2 describe-snapshot-attribute --snapshot-id "$AWS_SNAPSHOT_ID" --attribute createVolumePermission > "$WORKDIR/snapshot-attributes.json"
+
+  SHARED_ID=$(jq -r '.CreateVolumePermissions[0].UserId' "$WORKDIR/snapshot-attributes.json")
+  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
+    SHARE_OK=0
+  fi
+
+  # Verify that the ec2 ami was shared
+  $AWS_CMD ec2 describe-image-attribute --image-id "$AMI_IMAGE_ID" --attribute launchPermission > "$WORKDIR/ami-attributes.json"
+
+  SHARED_ID=$(jq -r '.LaunchPermissions[0].UserId' "$WORKDIR/ami-attributes.json")
+  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
+    SHARE_OK=0
+  fi
+
+  if [ "$SHARE_OK" != 1 ]; then
+    echo "EC2 snapshot wasn't shared with the AWS_API_TEST_SHARE_ACCOUNT. 😢"
+    exit 1
+  fi
+
+  # Create key-pair
+  $AWS_CMD ec2 create-key-pair --key-name "key-for-$AMI_IMAGE_ID" --query 'KeyMaterial' --output text > keypair.pem
+  chmod 400 ./keypair.pem
+
+  # Create an instance based on the ami
+  $AWS_CMD ec2 run-instances --image-id "$AMI_IMAGE_ID" --count 1 --instance-type t2.micro --key-name "key-for-$AMI_IMAGE_ID" --tag-specifications 'ResourceType=instance,Tags=[{Key=gitlab-ci-test,Value=true}]' > "$WORKDIR/instances.json"
+  AWS_INSTANCE_ID=$(jq -r '.Instances[].InstanceId' "$WORKDIR/instances.json")
+
+  $AWS_CMD ec2 wait instance-running --instance-ids "$AWS_INSTANCE_ID"
+
+  $AWS_CMD ec2 describe-instances --instance-ids "$AWS_INSTANCE_ID" > "$WORKDIR/instances.json"
+  HOST=$(jq -r '.Reservations[].Instances[].PublicIpAddress' "$WORKDIR/instances.json")
+
+  echo "⏱ Waiting for AWS instance to respond to ssh"
+  _instanceWaitSSH "$HOST"
+
+  # Verify image
+  _ssh="ssh -oStrictHostKeyChecking=no -i ./keypair.pem $SSH_USER@$HOST"
+  _instanceCheck "$_ssh"
+
+  # Check access to user1 and user2
+  check_groups=$(ssh -oStrictHostKeyChecking=no -i "${WORKDIR}/usertest" "user1@$HOST" -t 'groups')
+  if [[ $check_groups =~ "wheel" ]]; then
+   echo "✔️  user1 has the group wheel"
+  else
+    echo 'user1 should have the group wheel 😢'
+    exit 1
+  fi
+  check_groups=$(ssh -oStrictHostKeyChecking=no -i "${WORKDIR}/usertest" "user2@$HOST" -t 'groups')
+  if [[ $check_groups =~ "wheel" ]]; then
+    echo 'user2 should not have group wheel 😢'
+    exit 1
+  else
+   echo "✔️  user2 does not have the group wheel"
+  fi
+}
diff --git a/test/cases/api/azure.sh b/test/cases/api/azure.sh
new file mode 100644
index 000000000..76be691b8
--- /dev/null
+++ b/test/cases/api/azure.sh
@@ -0,0 +1,176 @@
+#!/usr/bin/bash
+
+source /usr/libexec/tests/osbuild-composer/api/common/common.sh
+
+# Check that needed variables are set to access Azure.
+function checkEnv() {
+  printenv AZURE_TENANT_ID AZURE_SUBSCRIPTION_ID AZURE_RESOURCE_GROUP AZURE_LOCATION V2_AZURE_CLIENT_ID V2_AZURE_CLIENT_SECRET > /dev/null
+}
+
+function cleanup() {
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AZURE_CMD="${AZURE_CMD:-}"
+  AZURE_IMAGE_NAME="${AZURE_IMAGE_NAME:-}"
+  AZURE_INSTANCE_NAME="${AZURE_INSTANCE_NAME:-}"
+
+  # do not run clean-up if the image name is not yet defined
+  if [[ -n "$AZURE_CMD" && -n "$AZURE_IMAGE_NAME" ]]; then
+    # Re-get the vm_details in case the VM creation is failed.
+    [ -f "$WORKDIR/vm_details.json" ] || $AZURE_CMD vm show --name "$AZURE_INSTANCE_NAME" --resource-group "$AZURE_RESOURCE_GROUP" --show-details > "$WORKDIR/vm_details.json"
+    # Get all the resources ids
+    VM_ID=$(jq -r '.id' "$WORKDIR"/vm_details.json)
+    OSDISK_ID=$(jq -r '.storageProfile.osDisk.managedDisk.id' "$WORKDIR"/vm_details.json)
+    NIC_ID=$(jq -r '.networkProfile.networkInterfaces[0].id' "$WORKDIR"/vm_details.json)
+    $AZURE_CMD network nic show --ids "$NIC_ID" > "$WORKDIR"/nic_details.json
+    NSG_ID=$(jq -r '.networkSecurityGroup.id' "$WORKDIR"/nic_details.json)
+    PUBLICIP_ID=$(jq -r '.ipConfigurations[0].publicIpAddress.id' "$WORKDIR"/nic_details.json)
+
+    # Delete resources. Some resources must be removed in order:
+    # - Delete VM prior to any other resources
+    # - Delete NIC prior to NSG, public-ip
+    # Left Virtual Network and Storage Account there because other tests in the same resource group will reuse them
+    for id in "$VM_ID" "$OSDISK_ID" "$NIC_ID" "$NSG_ID" "$PUBLICIP_ID"; do
+      echo "Deleting $id..."
+      $AZURE_CMD resource delete --ids "$id"
+    done
+
+    # Delete image after VM deleting.
+    $AZURE_CMD image delete --resource-group "$AZURE_RESOURCE_GROUP" --name "$AZURE_IMAGE_NAME"
+    # find a storage account by its tag
+    AZURE_STORAGE_ACCOUNT=$($AZURE_CMD resource list --tag imageBuilderStorageAccount=location="$AZURE_LOCATION" | jq -r .[0].name)
+    AZURE_CONNECTION_STRING=$($AZURE_CMD storage account show-connection-string --name "$AZURE_STORAGE_ACCOUNT" | jq -r .connectionString)
+    $AZURE_CMD storage blob delete --container-name imagebuilder --name "$AZURE_IMAGE_NAME".vhd --account-name "$AZURE_STORAGE_ACCOUNT" --connection-string "$AZURE_CONNECTION_STRING"
+  fi
+}
+
+function installClient() {
+  if ! hash az; then
+    echo "Using 'azure-cli' from a container"
+    sudo "${CONTAINER_RUNTIME}" pull "${CONTAINER_IMAGE_CLOUD_TOOLS}"
+
+    # directory mounted to the container, in which azure-cli stores the credentials after logging in
+    AZURE_CMD_CREDS_DIR="${WORKDIR}/azure-cli_credentials"
+    mkdir "${AZURE_CMD_CREDS_DIR}"
+
+    AZURE_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+      -v ${AZURE_CMD_CREDS_DIR}:/root/.azure:Z \
+      -v ${WORKDIR}:${WORKDIR}:Z \
+      ${CONTAINER_IMAGE_CLOUD_TOOLS} az"
+  else
+    echo "Using pre-installed 'azure-cli' from the system"
+    AZURE_CMD="az"
+  fi
+  $AZURE_CMD version
+}
+
+function createReqFile() {
+  AZURE_IMAGE_NAME="image-$TEST_ID"
+
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "filesystem": [
+      {
+        "mountpoint": "/var",
+        "min_size": 262144000
+      }
+    ],
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ]${SUBSCRIPTION_BLOCK}
+  },
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "${IMAGE_TYPE}",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "upload_options": {
+      "tenant_id": "${AZURE_TENANT_ID}",
+      "subscription_id": "${AZURE_SUBSCRIPTION_ID}",
+      "resource_group": "${AZURE_RESOURCE_GROUP}",
+      "location": "${AZURE_LOCATION}",
+      "image_name": "${AZURE_IMAGE_NAME}"
+    }
+  }
+}
+EOF
+}
+
+function checkUploadStatusOptions() {
+  local IMAGE_NAME
+  IMAGE_NAME=$(echo "$UPLOAD_OPTIONS" | jq -r '.image_name')
+
+  test "$IMAGE_NAME" = "$AZURE_IMAGE_NAME"
+}
+
+# Verify image in Azure
+function verify() {
+  set +x
+  $AZURE_CMD login --service-principal --username "${V2_AZURE_CLIENT_ID}" --password "${V2_AZURE_CLIENT_SECRET}" --tenant "${AZURE_TENANT_ID}"
+  set -x
+
+  # verify that the image exists and tag it
+  $AZURE_CMD image show --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}"
+  $AZURE_CMD image update --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}" --tags gitlab-ci-test=true
+
+  # Verify that the image boots and have customizations applied
+  # Create SSH keys to use
+  AZURE_SSH_KEY="$WORKDIR/id_azure"
+  ssh-keygen -t rsa-sha2-512 -f "$AZURE_SSH_KEY" -C "$SSH_USER" -N ""
+
+  # Create network resources with predictable names
+  $AZURE_CMD network nsg create --resource-group "$AZURE_RESOURCE_GROUP" --name "nsg-$TEST_ID" --location "$AZURE_LOCATION" --tags gitlab-ci-test=true
+  $AZURE_CMD network nsg rule create --resource-group "$AZURE_RESOURCE_GROUP" \
+      --nsg-name "nsg-$TEST_ID" \
+      --name SSH \
+      --priority 1001 \
+      --access Allow \
+      --protocol Tcp \
+      --destination-address-prefixes '*' \
+      --destination-port-ranges 22 \
+      --source-port-ranges '*' \
+      --source-address-prefixes '*'
+  $AZURE_CMD network vnet create --resource-group "$AZURE_RESOURCE_GROUP" \
+      --name "vnet-$TEST_ID" \
+      --subnet-name "snet-$TEST_ID" \
+      --location "$AZURE_LOCATION" \
+      --tags gitlab-ci-test=true
+  $AZURE_CMD network public-ip create --resource-group "$AZURE_RESOURCE_GROUP" --name "ip-$TEST_ID" --location "$AZURE_LOCATION" --tags gitlab-ci-test=true
+  $AZURE_CMD network nic create --resource-group "$AZURE_RESOURCE_GROUP" \
+      --name "iface-$TEST_ID" \
+      --subnet "snet-$TEST_ID" \
+      --vnet-name "vnet-$TEST_ID" \
+      --network-security-group "nsg-$TEST_ID" \
+      --public-ip-address "ip-$TEST_ID" \
+      --location "$AZURE_LOCATION" \
+      --tags gitlab-ci-test=true
+
+  # create the instance
+  AZURE_INSTANCE_NAME="vm-$TEST_ID"
+  $AZURE_CMD vm create --name "$AZURE_INSTANCE_NAME" \
+    --resource-group "$AZURE_RESOURCE_GROUP" \
+    --image "$AZURE_IMAGE_NAME" \
+    --size "Standard_B1s" \
+    --admin-username "$SSH_USER" \
+    --ssh-key-values "$AZURE_SSH_KEY.pub" \
+    --authentication-type "ssh" \
+    --location "$AZURE_LOCATION" \
+    --nics "iface-$TEST_ID" \
+    --os-disk-name "disk-$TEST_ID" \
+    --tags gitlab-ci-test=true
+  $AZURE_CMD vm show --name "$AZURE_INSTANCE_NAME" --resource-group "$AZURE_RESOURCE_GROUP" --show-details > "$WORKDIR/vm_details.json"
+  HOST=$(jq -r '.publicIps' "$WORKDIR/vm_details.json")
+
+  echo "⏱  Waiting for Azure instance to respond to ssh"
+  _instanceWaitSSH "$HOST"
+
+  # Verify image
+  _ssh="ssh -oStrictHostKeyChecking=no -i $AZURE_SSH_KEY $SSH_USER@$HOST"
+  _instanceCheck "$_ssh"
+}
diff --git a/test/cases/api/common/aws.sh b/test/cases/api/common/aws.sh
new file mode 100644
index 000000000..14c315e54
--- /dev/null
+++ b/test/cases/api/common/aws.sh
@@ -0,0 +1,33 @@
+#!/usr/bin/bash
+
+function installClient() {
+  if ! hash aws; then
+    echo "Using 'awscli' from a container"
+    sudo "${CONTAINER_RUNTIME}" pull "${CONTAINER_IMAGE_CLOUD_TOOLS}"
+
+    AWS_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+      -e AWS_ACCESS_KEY_ID=${V2_AWS_ACCESS_KEY_ID} \
+      -e AWS_SECRET_ACCESS_KEY=${V2_AWS_SECRET_ACCESS_KEY} \
+      -v ${WORKDIR}:${WORKDIR}:Z \
+      ${CONTAINER_IMAGE_CLOUD_TOOLS} aws --region $AWS_REGION --output json --color on"
+  else
+    echo "Using pre-installed 'aws' from the system"
+    AWS_CMD="aws --region $AWS_REGION --output json --color on"
+  fi
+  $AWS_CMD --version
+
+  if ! hash govc; then
+    greenprint "Installing govc"
+    pushd "${WORKDIR}" || exit 1
+    curl -Ls --retry 5 --output govc.gz \
+         https://github.com/vmware/govmomi/releases/download/v0.24.0/govc_linux_amd64.gz
+    gunzip -f govc.gz
+    GOVC_CMD="${WORKDIR}/govc"
+    chmod +x "${GOVC_CMD}"
+    popd || exit 1
+  else
+     echo "Using pre-installed 'govc' from the system"
+     GOVC_CMD="govc"
+  fi
+  $GOVC_CMD version
+}
diff --git a/test/cases/api/common/common.sh b/test/cases/api/common/common.sh
new file mode 100644
index 000000000..56fe11296
--- /dev/null
+++ b/test/cases/api/common/common.sh
@@ -0,0 +1,46 @@
+#!/usr/bin/bash
+
+# Reusable function, which waits for a given host to respond to SSH
+function _instanceWaitSSH() {
+  local HOST="$1"
+
+  for LOOP_COUNTER in {0..30}; do
+      if ssh-keyscan "$HOST" > /dev/null 2>&1; then
+          echo "SSH is up!"
+          ssh-keyscan "$HOST" | sudo tee -a /root/.ssh/known_hosts
+          break
+      fi
+      echo "Retrying in 5 seconds... $LOOP_COUNTER"
+      sleep 5
+  done
+}
+
+function _instanceCheck() {
+  echo "✔️ Instance checking"
+  local _ssh="$1"
+
+  # Check if postgres is installed
+  $_ssh rpm -q postgresql dummy
+
+  # Verify subscribe status. Loop check since the system may not be registered such early(RHEL only)
+  if [[ "$ID" == "rhel" ]]; then
+    set +eu
+    for LOOP_COUNTER in {1..10}; do
+        subscribe_org_id=$($_ssh sudo subscription-manager identity | grep 'org ID')
+        if [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]; then
+            echo "System is subscribed."
+            break
+        else
+            echo "System is not subscribed. Retrying in 30 seconds...($LOOP_COUNTER/10)"
+            sleep 30
+        fi
+    done
+    set -eu
+    [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]
+
+    # Unregister subscription
+    $_ssh sudo subscription-manager unregister
+  else
+    echo "Not RHEL OS. Skip subscription check."
+  fi
+}
diff --git a/test/cases/api/common/s3.sh b/test/cases/api/common/s3.sh
new file mode 100644
index 000000000..bf194a087
--- /dev/null
+++ b/test/cases/api/common/s3.sh
@@ -0,0 +1,435 @@
+#!/usr/bin/bash
+
+function createReqFileEdge() {
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ],
+    "users":[
+      {
+        "name": "user1",
+        "groups": ["wheel"],
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      },
+      {
+        "name": "user2",
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      }
+    ]
+  },
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "${IMAGE_TYPE}",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "ostree": {
+      "ref": "${OSTREE_REF}"
+    },
+    "upload_options": {
+      "region": "${AWS_REGION}"
+    }
+  }
+}
+EOF
+}
+
+function createReqFileGuest() {
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ]${SUBSCRIPTION_BLOCK},
+    "users":[
+      {
+        "name": "user1",
+        "groups": ["wheel"],
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      },
+      {
+        "name": "user2",
+        "key": "$(cat "${WORKDIR}/usertest.pub")"
+      }
+    ]
+  },
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "${IMAGE_TYPE}",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "upload_options": {
+      "region": "${AWS_REGION}"
+    }
+  }
+}
+EOF
+}
+
+# the VSphere test case does not create any additional users,
+# since this is not supported by the service UI
+function createReqFileVSphere() {
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ]${SUBSCRIPTION_BLOCK}
+  },
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "${IMAGE_TYPE}",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "upload_options": {
+      "region": "${AWS_REGION}"
+    }
+  }
+}
+EOF
+}
+
+
+# Create a cloud-int user-data file
+#
+# Returns:
+#   - path to the user-data file
+#
+# Arguments:
+#   $1 - default username
+#   $2 - path to the SSH public key to set as authorized for the user
+function createCIUserdata() {
+    local _user="$1"
+    local _ssh_pubkey_path="$2"
+
+    local _ci_userdata_dir
+    _ci_userdata_dir="$(mktemp -d -p "${WORKDIR}")"
+    local _ci_userdata_path="${_ci_userdata_dir}/user-data"
+
+    cat > "${_ci_userdata_path}" <<EOF
+#cloud-config
+users:
+    - name: "${_user}"
+      sudo: "ALL=(ALL) NOPASSWD:ALL"
+      ssh_authorized_keys:
+          - "$(cat "${_ssh_pubkey_path}")"
+EOF
+
+    echo "${_ci_userdata_path}"
+}
+
+# Create a cloud-int meta-data file
+#
+# Returns:
+#   - path to the meta-data file
+#
+# Arguments:
+#   $1 - VM name
+function createCIMetadata() {
+    local _vm_name="$1"
+
+    local _ci_metadata_dir
+    _ci_metadata_dir="$(mktemp -d -p "${WORKDIR}")"
+    local _ci_metadata_path="${_ci_metadata_dir}/meta-data"
+
+    cat > "${_ci_metadata_path}" <<EOF
+instance-id: ${_vm_name}
+local-hostname: ${_vm_name}
+EOF
+
+    echo "${_ci_metadata_path}"
+}
+
+# Create an ISO with the provided cloud-init user-data file
+#
+# Returns:
+#   - path to the created ISO file
+#
+# Arguments:
+#   $1 - path to the cloud-init user-data file
+#   $2 - path to the cloud-init meta-data file
+function createCIUserdataISO() {
+    local _ci_userdata_path="$1"
+    local _ci_metadata_path="$2"
+
+    local _iso_path
+    _iso_path="$(mktemp -p "${WORKDIR}" --suffix .iso)"
+    mkisofs \
+      -input-charset "utf-8" \
+      -output "${_iso_path}" \
+      -volid "cidata" \
+      -joliet \
+      -rock \
+      -quiet \
+      -graft-points \
+      "${_ci_userdata_path}" \
+      "${_ci_metadata_path}"
+
+    echo "${_iso_path}"
+}
+
+# Create a cloud-int user-data file
+#
+# Returns:
+#   - path to the user-data file
+#
+# Arguments:
+#   $1 - default username
+#   $2 - path to the SSH public key to set as authorized for the user
+function createCIUserdata() {
+    local _user="$1"
+    local _ssh_pubkey_path="$2"
+
+    local _ci_userdata_dir
+    _ci_userdata_dir="$(mktemp -d -p "${WORKDIR}")"
+    local _ci_userdata_path="${_ci_userdata_dir}/user-data"
+
+    cat > "${_ci_userdata_path}" <<EOF
+#cloud-config
+users:
+    - name: "${_user}"
+      sudo: "ALL=(ALL) NOPASSWD:ALL"
+      ssh_authorized_keys:
+          - "$(cat "${_ssh_pubkey_path}")"
+EOF
+
+    echo "${_ci_userdata_path}"
+}
+
+# Create a cloud-int meta-data file
+#
+# Returns:
+#   - path to the meta-data file
+#
+# Arguments:
+#   $1 - VM name
+function createCIMetadata() {
+    local _vm_name="$1"
+
+    local _ci_metadata_dir
+    _ci_metadata_dir="$(mktemp -d -p "${WORKDIR}")"
+    local _ci_metadata_path="${_ci_metadata_dir}/meta-data"
+
+    cat > "${_ci_metadata_path}" <<EOF
+instance-id: ${_vm_name}
+local-hostname: ${_vm_name}
+EOF
+
+    echo "${_ci_metadata_path}"
+}
+
+# Create an ISO with the provided cloud-init user-data file
+#
+# Returns:
+#   - path to the created ISO file
+#
+# Arguments:
+#   $1 - path to the cloud-init user-data file
+#   $2 - path to the cloud-init meta-data file
+function createCIUserdataISO() {
+    local _ci_userdata_path="$1"
+    local _ci_metadata_path="$2"
+
+    local _iso_path
+    _iso_path="$(mktemp -p "${WORKDIR}" --suffix .iso)"
+    mkisofs \
+      -input-charset "utf-8" \
+      -output "${_iso_path}" \
+      -volid "cidata" \
+      -joliet \
+      -rock \
+      -quiet \
+      -graft-points \
+      "${_ci_userdata_path}" \
+      "${_ci_metadata_path}"
+
+    echo "${_iso_path}"
+}
+
+# verify edge commit content
+function verifyEdgeCommit() {
+    filename="$1"
+    greenprint "Verifying contents of ${filename}"
+
+    # extract tarball and save file list to artifacts directroy
+    local COMMIT_DIR
+    COMMIT_DIR="${WORKDIR}/edge-commit"
+    mkdir -p "${COMMIT_DIR}"
+    tar xvf "${filename}" -C "${COMMIT_DIR}" > "${ARTIFACTS}/edge-commit-filelist.txt"
+
+    # Verify that the commit contains the ref we defined in the request
+    sudo dnf install -y ostree
+    local COMMIT_REF
+    COMMIT_REF=$(ostree refs --repo "${COMMIT_DIR}/repo")
+    if [[ "${COMMIT_REF}" !=  "${OSTREE_REF}" ]]; then
+        echo "Commit ref in archive does not match request 😠"
+        exit 1
+    fi
+
+    local TAR_COMMIT_ID
+    TAR_COMMIT_ID=$(ostree rev-parse --repo "${COMMIT_DIR}/repo" "${OSTREE_REF}")
+    API_COMMIT_ID_V2=$(curl \
+        --silent \
+        --show-error \
+        --cacert /etc/osbuild-composer/ca-crt.pem \
+        --key /etc/osbuild-composer/client-key.pem \
+        --cert /etc/osbuild-composer/client-crt.pem \
+        https://localhost/api/image-builder-composer/v2/composes/"$COMPOSE_ID"/metadata | jq -r '.ostree_commit')
+
+    if [[ "${API_COMMIT_ID_V2}" != "${TAR_COMMIT_ID}" ]]; then
+        echo "Commit ID returned from API does not match Commit ID in archive 😠"
+        exit 1
+    fi
+
+}
+
+# Verify image blobs from s3
+function verifyDisk() {
+    filename="$1"
+    greenprint "Verifying contents of ${filename}"
+
+    infofile="${filename}-info.json"
+    sudo /usr/libexec/osbuild-composer-test/image-info "${filename}" | tee "${infofile}" > /dev/null
+
+    # save image info to artifacts
+    cp -v "${infofile}" "${ARTIFACTS}/image-info.json"
+
+    # check compose request users in passwd
+    if ! jq .passwd "${infofile}" | grep -q "user1"; then
+        greenprint "❌ user1 not found in passwd file"
+        exit 1
+    fi
+    if ! jq .passwd "${infofile}" | grep -q "user2"; then
+        greenprint "❌ user2 not found in passwd file"
+        exit 1
+    fi
+    # check packages for postgresql
+    if ! jq .packages "${infofile}" | grep -q "postgresql"; then
+        greenprint "❌ postgresql not found in packages"
+        exit 1
+    fi
+
+    greenprint "✅ ${filename} image info verified"
+}
+
+# Verify VMDK image in VSphere
+function verifyInVSphere() {
+    local _filename="$1"
+    greenprint "Verifying VMDK image: ${_filename}"
+
+    # Create SSH keys to use
+    local _vsphere_ssh_key="${WORKDIR}/vsphere_ssh_key"
+    ssh-keygen -t rsa-sha2-512 -f "${_vsphere_ssh_key}" -C "${SSH_USER}" -N ""
+
+    VSPHERE_VM_NAME="osbuild-composer-vm-${TEST_ID}"
+
+    # create cloud-init ISO with the configuration
+    local _ci_userdata_path
+    _ci_userdata_path="$(createCIUserdata "${SSH_USER}" "${_vsphere_ssh_key}.pub")"
+    local _ci_metadata_path
+    _ci_metadata_path="$(createCIMetadata "${VSPHERE_VM_NAME}")"
+    greenprint "💿 Creating cloud-init user-data ISO"
+    local _ci_iso_path
+    _ci_iso_path="$(createCIUserdataISO "${_ci_userdata_path}" "${_ci_metadata_path}")"
+
+    VSPHERE_IMAGE_NAME="${VSPHERE_VM_NAME}.vmdk"
+    mv "${_filename}" "${WORKDIR}/${VSPHERE_IMAGE_NAME}"
+
+    # import the built VMDK image to VSphere
+    # import.vmdk seems to be creating the provided directory and
+    # if one with this name exists, it appends "_<number>" to the name
+    greenprint "💿 ⬆️ Importing the converted VMDK image to VSphere"
+    $GOVC_CMD import.vmdk \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -ds="${GOVMOMI_DATASTORE}" \
+        "${WORKDIR}/${VSPHERE_IMAGE_NAME}" \
+        "${VSPHERE_VM_NAME}"
+
+    # create the VM, but don't start it
+    greenprint "🖥️ Creating VM in VSphere"
+    $GOVC_CMD vm.create \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -pool="${GOVMOMI_CLUSTER}"/Resources \
+        -ds="${GOVMOMI_DATASTORE}" \
+        -folder="${GOVMOMI_FOLDER}" \
+        -net="${GOVMOMI_NETWORK}" \
+        -net.adapter=vmxnet3 \
+        -m=4096 -c=2 -g=rhel8_64Guest -on=true -firmware=bios \
+        -disk="${VSPHERE_VM_NAME}/${VSPHERE_IMAGE_NAME}" \
+        -disk.controller=ide \
+        -on=false \
+        "${VSPHERE_VM_NAME}"
+
+    # upload ISO, create CDROM device and insert the ISO in it
+    greenprint "💿 ⬆️ Uploading the cloud-init user-data ISO to VSphere"
+    VSPHERE_CIDATA_ISO_PATH="${VSPHERE_VM_NAME}/cidata.iso"
+    $GOVC_CMD datastore.upload \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -ds="${GOVMOMI_DATASTORE}" \
+        "${_ci_iso_path}" \
+        "${VSPHERE_CIDATA_ISO_PATH}"
+
+    local _cdrom_device
+    greenprint "🖥️ + 💿 Adding a CD-ROM device to the VM"
+    _cdrom_device="$($GOVC_CMD device.cdrom.add \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -vm "${VSPHERE_VM_NAME}")"
+
+    greenprint "💿 Inserting the cloud-init ISO into the CD-ROM device"
+    $GOVC_CMD device.cdrom.insert \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -ds="${GOVMOMI_DATASTORE}" \
+        -vm "${VSPHERE_VM_NAME}" \
+        -device "${_cdrom_device}" \
+        "${VSPHERE_CIDATA_ISO_PATH}"
+
+    # start the VM
+    greenprint "🔌 Powering up the VSphere VM"
+    $GOVC_CMD vm.power \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        -on "${VSPHERE_VM_NAME}"
+
+    HOST=$($GOVC_CMD vm.ip \
+        -u "${GOVMOMI_USERNAME}:${GOVMOMI_PASSWORD}@${GOVMOMI_URL}" \
+        -k=true \
+        -dc="${GOVC_DATACENTER}" \
+        "${VSPHERE_VM_NAME}")
+    greenprint "⏱ Waiting for the VSphere VM to respond to ssh"
+    _instanceWaitSSH "${HOST}"
+
+    _ssh="ssh -oStrictHostKeyChecking=no -i ${_vsphere_ssh_key} $SSH_USER@$HOST"
+    _instanceCheck "${_ssh}"
+
+    greenprint "✅ Successfully verified VSphere image with cloud-init"
+}
diff --git a/test/cases/api/gcp.sh b/test/cases/api/gcp.sh
new file mode 100644
index 000000000..267b8159c
--- /dev/null
+++ b/test/cases/api/gcp.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/bash
+
+source /usr/libexec/tests/osbuild-composer/api/common/common.sh
+
+# Check that needed variables are set to access GCP.
+function checkEnv() {
+  printenv GOOGLE_APPLICATION_CREDENTIALS GCP_BUCKET GCP_REGION GCP_API_TEST_SHARE_ACCOUNT > /dev/null
+}
+
+function cleanup() {
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  GCP_CMD="${GCP_CMD:-}"
+  GCP_IMAGE_NAME="${GCP_IMAGE_NAME:-}"
+  GCP_INSTANCE_NAME="${GCP_INSTANCE_NAME:-}"
+  GCP_ZONE="${GCP_ZONE:-}"
+
+  if [ -n "$GCP_CMD" ]; then
+    $GCP_CMD compute instances delete --zone="$GCP_ZONE" "$GCP_INSTANCE_NAME"
+    $GCP_CMD compute images delete "$GCP_IMAGE_NAME"
+  fi
+}
+
+
+function installClient() {
+  if ! hash gcloud; then
+    echo "Using 'gcloud' from a container"
+    sudo "${CONTAINER_RUNTIME}" pull "${CONTAINER_IMAGE_CLOUD_TOOLS}"
+
+    # directory mounted to the container, in which gcloud stores the credentials after logging in
+    GCP_CMD_CREDS_DIR="${WORKDIR}/gcloud_credentials"
+    mkdir "${GCP_CMD_CREDS_DIR}"
+
+    GCP_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+      -v ${GCP_CMD_CREDS_DIR}:/root/.config/gcloud:Z \
+      -v ${GOOGLE_APPLICATION_CREDENTIALS}:${GOOGLE_APPLICATION_CREDENTIALS}:Z \
+      -v ${WORKDIR}:${WORKDIR}:Z \
+      ${CONTAINER_IMAGE_CLOUD_TOOLS} gcloud --format=json"
+  else
+    echo "Using pre-installed 'gcloud' from the system"
+    GCP_CMD="gcloud --format=json --quiet"
+  fi
+  $GCP_CMD --version
+}
+
+
+function createReqFile() {
+  # constrains for GCP resource IDs:
+  # - max 62 characters
+  # - must be a match of regex '[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?|[1-9][0-9]{0,19}'
+  #
+  # use sha224sum to get predictable 56 characters long testID without invalid characters
+  GCP_TEST_ID_HASH="$(echo -n "$TEST_ID" | sha224sum - | sed -E 's/([a-z0-9])\s+-/\1/')"
+
+  GCP_IMAGE_NAME="image-$GCP_TEST_ID_HASH"
+
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "filesystem": [
+      {
+        "mountpoint": "/var",
+        "min_size": 262144000
+      }
+    ],
+    "payload_repositories": [
+      {
+        "baseurl": "$PAYLOAD_REPO_URL"
+      }
+    ],
+    "packages": [
+      "postgresql",
+      "dummy"
+    ]${SUBSCRIPTION_BLOCK}
+  },
+  "image_request": {
+    "architecture": "$ARCH",
+    "image_type": "${IMAGE_TYPE}",
+    "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+    "upload_options": {
+      "bucket": "${GCP_BUCKET}",
+      "region": "${GCP_REGION}",
+      "image_name": "${GCP_IMAGE_NAME}",
+      "share_with_accounts": ["${GCP_API_TEST_SHARE_ACCOUNT}"]
+    }
+  }
+}
+EOF
+}
+
+
+function checkUploadStatusOptions() {
+  GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
+
+  local IMAGE_NAME
+  IMAGE_NAME=$(echo "$UPLOAD_OPTIONS" | jq -r '.image_name')
+  local PROJECT_ID
+  PROJECT_ID=$(echo "$UPLOAD_OPTIONS" | jq -r '.project_id')
+
+  test "$IMAGE_NAME" = "$GCP_IMAGE_NAME"
+  test "$PROJECT_ID" = "$GCP_PROJECT"
+}
+
+
+# Verify image in Compute Engine on GCP
+function verify() {
+  # Authenticate
+  $GCP_CMD auth activate-service-account --key-file "$GOOGLE_APPLICATION_CREDENTIALS"
+  # Extract and set the default project to be used for commands
+  GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
+  $GCP_CMD config set project "$GCP_PROJECT"
+
+  # Add "gitlab-ci-test" label to the image
+  $GCP_CMD compute images add-labels "$GCP_IMAGE_NAME" --labels=gitlab-ci-test=true
+
+  # Verify that the image was shared
+  SHARE_OK=1
+  $GCP_CMD compute images get-iam-policy "$GCP_IMAGE_NAME" > "$WORKDIR/image-iam-policy.json"
+  SHARED_ACCOUNT=$(jq -r '.bindings[0].members[0]' "$WORKDIR/image-iam-policy.json")
+  SHARED_ROLE=$(jq -r '.bindings[0].role' "$WORKDIR/image-iam-policy.json")
+  if [ "$SHARED_ACCOUNT" != "$GCP_API_TEST_SHARE_ACCOUNT" ] || [ "$SHARED_ROLE" != "roles/compute.imageUser" ]; then
+    SHARE_OK=0
+  fi
+
+  if [ "$SHARE_OK" != 1 ]; then
+    echo "GCP image wasn't shared with the GCP_API_TEST_SHARE_ACCOUNT. 😢"
+    exit 1
+  fi
+
+  # Verify that the image boots and have customizations applied
+  # Create SSH keys to use
+  GCP_SSH_KEY="$WORKDIR/id_google_compute_engine"
+  ssh-keygen -t rsa-sha2-512 -f "$GCP_SSH_KEY" -C "$SSH_USER" -N ""
+
+  # create the instance
+  # resource ID can have max 62 characters, the $GCP_TEST_ID_HASH contains 56 characters
+  GCP_INSTANCE_NAME="vm-$GCP_TEST_ID_HASH"
+
+  # Ensure that we use random GCP region with available 'IN_USE_ADDRESSES' quota
+  # We use the CI variable "GCP_REGION" as the base for expression to filter regions.
+  # It works best if the "GCP_REGION" is set to a storage multi-region, such as "us"
+  local GCP_COMPUTE_REGION
+  GCP_COMPUTE_REGION=$($GCP_CMD compute regions list --filter="name:$GCP_REGION* AND status=UP" | jq -r '.[] | select(.quotas[] as $quota | $quota.metric == "IN_USE_ADDRESSES" and $quota.limit > $quota.usage) | .name' | shuf -n1)
+
+  # Randomize the used GCP zone to prevent hitting "exhausted resources" error on each test re-run
+  GCP_ZONE=$($GCP_CMD compute zones list --filter="region=$GCP_COMPUTE_REGION AND status=UP" | jq -r '.[].name' | shuf -n1)
+
+  # Pick the smallest '^n\d-standard-\d$' machine type from those available in the zone
+  local GCP_MACHINE_TYPE
+  GCP_MACHINE_TYPE=$($GCP_CMD compute machine-types list --filter="zone=$GCP_ZONE AND name~^n\d-standard-\d$" | jq -r '.[].name' | sort | head -1)
+
+  $GCP_CMD compute instances create "$GCP_INSTANCE_NAME" \
+    --zone="$GCP_ZONE" \
+    --image-project="$GCP_PROJECT" \
+    --image="$GCP_IMAGE_NAME" \
+    --machine-type="$GCP_MACHINE_TYPE" \
+    --labels=gitlab-ci-test=true
+
+  HOST=$($GCP_CMD compute instances describe "$GCP_INSTANCE_NAME" --zone="$GCP_ZONE" --format='get(networkInterfaces[0].accessConfigs[0].natIP)')
+
+  echo "⏱ Waiting for GCP instance to respond to ssh"
+  _instanceWaitSSH "$HOST"
+
+  # Verify image
+  _ssh="$GCP_CMD compute ssh --strict-host-key-checking=no --ssh-key-file=$GCP_SSH_KEY --zone=$GCP_ZONE --quiet $SSH_USER@$GCP_INSTANCE_NAME --"
+  _instanceCheck "$_ssh"
+}
diff --git a/test/cases/api/generic.s3.sh b/test/cases/api/generic.s3.sh
new file mode 100644
index 000000000..b0e2926df
--- /dev/null
+++ b/test/cases/api/generic.s3.sh
@@ -0,0 +1,148 @@
+#!/usr/bin/bash
+
+source /usr/libexec/tests/osbuild-composer/api/common/common.sh
+source /usr/libexec/tests/osbuild-composer/api/common/s3.sh
+
+function checkEnv() {
+    printenv AWS_REGION > /dev/null
+}
+
+# Global var for ostree ref
+export OSTREE_REF="test/rhel/8/edge"
+
+function cleanup() {
+  MINIO_CONTAINER_NAME="${MINIO_CONTAINER_NAME:-}"
+  if [ -n "${MINIO_CONTAINER_NAME}" ]; then
+    sudo "${CONTAINER_RUNTIME}" kill "${MINIO_CONTAINER_NAME}"
+  fi
+}
+
+function installClient() {
+  local CONTAINER_MINIO_SERVER="quay.io/minio/minio:latest"
+  MINIO_CONTAINER_NAME="minio-server"
+  MINIO_ENDPOINT="http://localhost:9000"
+  local MINIO_ROOT_USER="X29DU5Q6C5NKDQ8PLGVT"
+  local MINIO_ROOT_PASSWORD
+  MINIO_ROOT_PASSWORD=$(date +%s | sha256sum | base64 | head -c 32 ; echo)
+  MINIO_BUCKET="ci-test"
+  local MINIO_REGION="${AWS_REGION}"
+  local MINIO_CREDENTIALS_FILE="/etc/osbuild-worker/minio-creds"
+
+  sudo "${CONTAINER_RUNTIME}" run --rm -d \
+    --name ${MINIO_CONTAINER_NAME} \
+    -p 9000:9000 \
+    -e MINIO_BROWSER=off \
+    -e MINIO_ROOT_USER="${MINIO_ROOT_USER}" \
+    -e MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}" \
+    ${CONTAINER_MINIO_SERVER} server /data
+
+  if ! hash aws; then
+    echo "Using 'awscli' from a container"
+    sudo "${CONTAINER_RUNTIME}" pull "${CONTAINER_IMAGE_CLOUD_TOOLS}"
+
+    AWS_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+      -e AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER} \
+      -e AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} \
+      -v ${WORKDIR}:${WORKDIR}:Z \
+      --network host \
+      ${CONTAINER_IMAGE_CLOUD_TOOLS} aws"
+  else
+    echo "Using pre-installed 'aws' from the system"
+    AWS_CMD="AWS_ACCESS_KEY_ID=${MINIO_ROOT_USER} \
+      AWS_SECRET_ACCESS_KEY=${MINIO_ROOT_PASSWORD} \
+      aws"
+  fi
+  AWS_CMD+=" --region $MINIO_REGION --output json --color on --endpoint-url $MINIO_ENDPOINT"
+  $AWS_CMD --version
+
+  # Configure the local server (retry until the service is up)
+  MINIO_CONFIGURE_RETRY=0
+  MINIO_CONFIGURE_MAX_RETRY=5
+  MINIO_RETRY_INTERVAL=15
+  until [ "${MINIO_CONFIGURE_RETRY}" -ge "${MINIO_CONFIGURE_MAX_RETRY}" ]
+  do
+      ${AWS_CMD} s3 ls && break
+      MINIO_CONFIGURE_RETRY=$((MINIO_CONFIGURE_RETRY + 1))
+    echo "Retrying [${MINIO_CONFIGURE_RETRY}/${MINIO_CONFIGURE_MAX_RETRY}] in ${MINIO_RETRY_INTERVAL}(s) "
+    sleep ${MINIO_RETRY_INTERVAL}
+  done
+
+  if [ "${MINIO_CONFIGURE_RETRY}" -ge "${MINIO_CONFIGURE_MAX_RETRY}" ]; then
+    echo "Failed to communicate with the MinIO server after ${MINIO_CONFIGURE_MAX_RETRY} attempts!"
+    exit 1
+  fi
+
+  # Create the bucket
+  ${AWS_CMD} s3 mb s3://${MINIO_BUCKET}
+
+  cat <<EOF | sudo tee "${MINIO_CREDENTIALS_FILE}"
+[default]
+aws_access_key_id = ${MINIO_ROOT_USER}
+aws_secret_access_key = ${MINIO_ROOT_PASSWORD}
+EOF
+
+  cat <<EOF | sudo tee "/etc/osbuild-worker/osbuild-worker.toml"
+[generic_s3]
+credentials = "${MINIO_CREDENTIALS_FILE}"
+endpoint = "${MINIO_ENDPOINT}"
+region = "${MINIO_REGION}"
+bucket = "${MINIO_BUCKET}"
+EOF
+
+  sudo systemctl restart "osbuild-worker@1"
+}
+
+# Unset AWS_REGION, region == "" in the request the worker will look for the generic s3
+# implementation
+function createReqFile() {
+    case ${IMAGE_TYPE} in
+        "$IMAGE_TYPE_EDGE_COMMIT"|"$IMAGE_TYPE_EDGE_CONTAINER"|"$IMAGE_TYPE_EDGE_INSTALLER"|"$IMAGE_TYPE_IMAGE_INSTALLER")
+            AWS_REGION='' createReqFileEdge
+            ;;
+        "$IMAGE_TYPE_VSPHERE")
+            AWS_REGION='' createReqFileGuest
+            ;;
+        "$IMAGE_TYPE_VSPHERE")
+            AWS_REGION='' createReqFileVSphere
+            ;;
+        *)
+            echo "Unknown s3 image type for: ${IMAGE_TYPE}"
+            exit 1
+    esac
+}
+
+function checkUploadStatusOptions() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # S3 URL contains endpoint and bucket name
+  echo "$S3_URL" | grep -F "$MINIO_ENDPOINT" -
+  echo "$S3_URL" | grep -F "$MINIO_BUCKET" -
+}
+
+# Verify s3 blobs
+function verify() {
+    local S3_URL
+    S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+    # Download the object using the Presigned URL and inspect
+    case ${IMAGE_TYPE} in
+        "$IMAGE_TYPE_EDGE_COMMIT")
+            curl "${S3_URL}" --output "${WORKDIR}/edge-commit.tar"
+            verifyEdgeCommit "${WORKDIR}/edge-commit.tar"
+            ;;
+        "${IMAGE_TYPE_GUEST}")
+            curl "${S3_URL}" --output "${WORKDIR}/disk.qcow2"
+            verifyDisk "${WORKDIR}/disk.qcow2"
+            ;;
+
+        "${IMAGE_TYPE_VSPHERE}")
+            curl "${S3_URL}" --output "${WORKDIR}/disk.vmdk"
+            verifyInVSphere "${WORKDIR}/disk.vmdk"
+            ;;
+        *)
+            greenprint "No validation method for image type ${IMAGE_TYPE}"
+            ;;
+    esac
+
+    greenprint "✅ Successfully verified S3 object"
+}
diff --git a/test/data/manifests/fedora_34-aarch64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_34-aarch64-fedora_iot_commit-boot.json
index 469b542c0..87f7637b2 100644
--- a/test/data/manifests/fedora_34-aarch64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_34-aarch64-fedora_iot_commit-boot.json
@@ -1888,7 +1888,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5421,7 +5421,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -9081,7 +9081,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "acl",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_34-aarch64-fedora_iot_container-boot.json b/test/data/manifests/fedora_34-aarch64-fedora_iot_container-boot.json
index 27ef511d9..0a78fd42f 100644
--- a/test/data/manifests/fedora_34-aarch64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_34-aarch64-fedora_iot_container-boot.json
@@ -1888,7 +1888,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5421,7 +5421,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -12419,7 +12419,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "acl",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_34-x86_64-container-boot.json b/test/data/manifests/fedora_34-x86_64-container-boot.json
index a8b366d97..f18ecd9a3 100644
--- a/test/data/manifests/fedora_34-x86_64-container-boot.json
+++ b/test/data/manifests/fedora_34-x86_64-container-boot.json
@@ -1224,22 +1224,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:5a4b0cc50d9929ef558617fecd879f80a459379047ec77bcc238e5b305258bf5",
                     "options": {
@@ -3391,9 +3375,6 @@
           "sha256:7b574e38bfd9854f3a4411a73df63c32d65cd0511577461cc2596049501a3725": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-fedora-20210512/Packages/t/tar-1.34-1.fc34.x86_64.rpm"
           },
-          "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-9.fc34.noarch.rpm"
-          },
           "sha256:7d59ff94c9fa323e68c6f9d5b0ccc06075847d77775211f4acbee8f54d8975ae": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-fedora-20210512/Packages/s/sudo-1.9.5p2-1.fc34.x86_64.rpm"
           },
@@ -3691,9 +3672,6 @@
           "sha256:d6762e1d533b1ca2ccc787d7394fd9ddeeb73845cdf55a5d1f93fbc53b716d8e": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-fedora-20210512/Packages/l/libsolv-0.7.17-3.fc34.x86_64.rpm"
           },
-          "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-9.fc34.x86_64.rpm"
-          },
           "sha256:da676955f475492f17c573bc526d47c37e34919d2a6ed33ec4573a4de0cab2a7": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-fedora-20210512/Packages/l/lz4-libs-1.9.3-2.fc34.x86_64.rpm"
           },
@@ -5263,26 +5241,6 @@
         "checksum": "sha256:9d3e2e24cce87a77671ec717b3c7d9a8aa584768af441c86b0aa7565d6691fdc",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-9.fc34.x86_64.rpm",
-        "checksum": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-9.fc34.noarch.rpm",
-        "checksum": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
diff --git a/test/data/manifests/fedora_34-x86_64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_34-x86_64-fedora_iot_commit-boot.json
index c24f6bbed..470a427f4 100644
--- a/test/data/manifests/fedora_34-x86_64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_34-x86_64-fedora_iot_commit-boot.json
@@ -1248,22 +1248,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:5a4b0cc50d9929ef558617fecd879f80a459379047ec77bcc238e5b305258bf5",
                     "options": {
@@ -1912,7 +1896,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5533,7 +5517,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -8455,26 +8439,6 @@
         "checksum": "sha256:9d3e2e24cce87a77671ec717b3c7d9a8aa584768af441c86b0aa7565d6691fdc",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-9.fc34.x86_64.rpm",
-        "checksum": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-9.fc34.noarch.rpm",
-        "checksum": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9256,7 +9220,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "acl",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_34-x86_64-fedora_iot_commit_debug-boot.json b/test/data/manifests/fedora_34-x86_64-fedora_iot_commit_debug-boot.json
index 8c2298b2e..85f04b3df 100644
--- a/test/data/manifests/fedora_34-x86_64-fedora_iot_commit_debug-boot.json
+++ b/test/data/manifests/fedora_34-x86_64-fedora_iot_commit_debug-boot.json
@@ -1254,22 +1254,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:5a4b0cc50d9929ef558617fecd879f80a459379047ec77bcc238e5b305258bf5",
                     "options": {
@@ -1918,7 +1902,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5539,7 +5523,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -8461,26 +8445,6 @@
         "checksum": "sha256:9d3e2e24cce87a77671ec717b3c7d9a8aa584768af441c86b0aa7565d6691fdc",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-9.fc34.x86_64.rpm",
-        "checksum": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-9.fc34.noarch.rpm",
-        "checksum": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9262,7 +9226,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "acl",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_34-x86_64-fedora_iot_container-boot.json b/test/data/manifests/fedora_34-x86_64-fedora_iot_container-boot.json
index a5ed8a943..6a32d3d5f 100644
--- a/test/data/manifests/fedora_34-x86_64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_34-x86_64-fedora_iot_container-boot.json
@@ -1248,22 +1248,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:5a4b0cc50d9929ef558617fecd879f80a459379047ec77bcc238e5b305258bf5",
                     "options": {
@@ -1912,7 +1896,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5533,7 +5517,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -10019,26 +10003,6 @@
         "checksum": "sha256:9d3e2e24cce87a77671ec717b3c7d9a8aa584768af441c86b0aa7565d6691fdc",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-9.fc34.x86_64.rpm",
-        "checksum": "sha256:d999a3002712972253f264814ccc4cf2b0959a121c3cdd7e10a7c8423f4b6d32",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "9.fc34",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f34/f34-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-9.fc34.noarch.rpm",
-        "checksum": "sha256:7c274d3c094f73d2ee7bebc3b9b854b3303330eac9fa2d1f4461fd7460b34605",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -12612,7 +12576,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "acl",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_35-aarch64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_35-aarch64-fedora_iot_commit-boot.json
index d1577e7b5..33bcecf99 100644
--- a/test/data/manifests/fedora_35-aarch64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_35-aarch64-fedora_iot_commit-boot.json
@@ -1928,7 +1928,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5445,7 +5445,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -9152,7 +9152,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_35-aarch64-fedora_iot_container-boot.json b/test/data/manifests/fedora_35-aarch64-fedora_iot_container-boot.json
index e71a14f3f..b2506c25f 100644
--- a/test/data/manifests/fedora_35-aarch64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_35-aarch64-fedora_iot_container-boot.json
@@ -1928,7 +1928,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5445,7 +5445,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -12433,7 +12433,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_35-x86_64-container-boot.json b/test/data/manifests/fedora_35-x86_64-container-boot.json
index 2521f601e..fa2502562 100644
--- a/test/data/manifests/fedora_35-x86_64-container-boot.json
+++ b/test/data/manifests/fedora_35-x86_64-container-boot.json
@@ -1512,22 +1512,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:94104f196a74be27358ac696a3f6063d45f7c33df2785a42454b988141760564",
                     "options": {
@@ -3366,9 +3350,6 @@
           "sha256:52598fdd27be8d3ca7587b81ce86f03d1f17c66c0c8fd81ca3274cb7712a400e": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-fedora-20220106/Packages/d/diffutils-3.8-1.fc35.x86_64.rpm"
           },
-          "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-10.fc35.noarch.rpm"
-          },
           "sha256:59d8bf9aefb00d5ec8317c30561e05b9bd50d946ad3c9d5beaa4ec7c7d9a478d": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-fedora-20220106/Packages/l/libverto-0.3.2-2.fc35.x86_64.rpm"
           },
@@ -3627,9 +3608,6 @@
           "sha256:bcc4edb810df0371fa4a1ca69b143386542505009eff4a1a0a806d13093e5c9d": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-fedora-20220106/Packages/s/sqlite-libs-3.36.0-3.fc35.x86_64.rpm"
           },
-          "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-10.fc35.x86_64.rpm"
-          },
           "sha256:bdd8ac8d916149edaaa5790d444e9b18f9679854e06add58f3693e5500adbb56": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-fedora-20220106/Packages/d/dnf-4.9.0-1.fc35.noarch.rpm"
           },
@@ -5649,26 +5627,6 @@
         "checksum": "sha256:2a3580b96d36668085e21a9d6e11cc2a7d254bfba9855c66fbfc663ea1f211c8",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-10.fc35.x86_64.rpm",
-        "checksum": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-10.fc35.noarch.rpm",
-        "checksum": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
diff --git a/test/data/manifests/fedora_35-x86_64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_35-x86_64-fedora_iot_commit-boot.json
index 81e8c308f..601d05ca2 100644
--- a/test/data/manifests/fedora_35-x86_64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_35-x86_64-fedora_iot_commit-boot.json
@@ -1544,22 +1544,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:94104f196a74be27358ac696a3f6063d45f7c33df2785a42454b988141760564",
                     "options": {
@@ -1952,7 +1936,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5557,7 +5541,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -8846,26 +8830,6 @@
         "checksum": "sha256:2a3580b96d36668085e21a9d6e11cc2a7d254bfba9855c66fbfc663ea1f211c8",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-10.fc35.x86_64.rpm",
-        "checksum": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-10.fc35.noarch.rpm",
-        "checksum": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9327,7 +9291,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_35-x86_64-fedora_iot_commit_debug-boot.json b/test/data/manifests/fedora_35-x86_64-fedora_iot_commit_debug-boot.json
index 4ef6590af..67aeecb33 100644
--- a/test/data/manifests/fedora_35-x86_64-fedora_iot_commit_debug-boot.json
+++ b/test/data/manifests/fedora_35-x86_64-fedora_iot_commit_debug-boot.json
@@ -1550,22 +1550,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:94104f196a74be27358ac696a3f6063d45f7c33df2785a42454b988141760564",
                     "options": {
@@ -1958,7 +1942,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5563,7 +5547,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -8852,26 +8836,6 @@
         "checksum": "sha256:2a3580b96d36668085e21a9d6e11cc2a7d254bfba9855c66fbfc663ea1f211c8",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-10.fc35.x86_64.rpm",
-        "checksum": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-10.fc35.noarch.rpm",
-        "checksum": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9333,7 +9297,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_35-x86_64-fedora_iot_container-boot.json b/test/data/manifests/fedora_35-x86_64-fedora_iot_container-boot.json
index e55dcb1ca..39f5a82b3 100644
--- a/test/data/manifests/fedora_35-x86_64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_35-x86_64-fedora_iot_container-boot.json
@@ -1544,22 +1544,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:94104f196a74be27358ac696a3f6063d45f7c33df2785a42454b988141760564",
                     "options": {
@@ -1952,7 +1936,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5557,7 +5541,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -10383,26 +10367,6 @@
         "checksum": "sha256:2a3580b96d36668085e21a9d6e11cc2a7d254bfba9855c66fbfc663ea1f211c8",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-2.06-10.fc35.x86_64.rpm",
-        "checksum": "sha256:bd422838b08aee333b919798924814148118cd1d2ec7051a3d01f0161eec4184",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "10.fc35",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f35/f35-x86_64-updates-released-20220113/Packages/g/grub2-pc-modules-2.06-10.fc35.noarch.rpm",
-        "checksum": "sha256:59525eff25d7e8c661f3d2a5308d431234cbd5e4a70c4150c3a71639d0e0e136",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -12626,7 +12590,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_36-aarch64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_36-aarch64-fedora_iot_commit-boot.json
index f1d4b50bb..143fe90ac 100644
--- a/test/data/manifests/fedora_36-aarch64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_36-aarch64-fedora_iot_commit-boot.json
@@ -2128,7 +2128,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5635,7 +5635,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -9609,7 +9609,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_36-aarch64-fedora_iot_container-boot.json b/test/data/manifests/fedora_36-aarch64-fedora_iot_container-boot.json
index 35b5fa5e1..c1c7428ca 100644
--- a/test/data/manifests/fedora_36-aarch64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_36-aarch64-fedora_iot_container-boot.json
@@ -2128,7 +2128,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5635,7 +5635,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -12870,7 +12870,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_36-x86_64-container-boot.json b/test/data/manifests/fedora_36-x86_64-container-boot.json
index bb06935e9..e06fbcedc 100644
--- a/test/data/manifests/fedora_36-x86_64-container-boot.json
+++ b/test/data/manifests/fedora_36-x86_64-container-boot.json
@@ -554,22 +554,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:ca6570e80b38f61221d622cab52390fa65a1fe9abfb402cc4ce4990a83f8d0f3",
                     "options": {
@@ -3358,9 +3342,6 @@
           "sha256:44b8073e409d9b3d681fb39183db23496a0825503987de3db78a57be4b551e16": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/d/dnf-4.11.1-1.fc37.noarch.rpm"
           },
-          "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-2.06-28.fc37.x86_64.rpm"
-          },
           "sha256:4771a6fe8a310ca1cc93c10030909c526423d114dc5cc74c0332282d3f4ab914": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/p/python-unversioned-command-3.10.3-1.fc37.noarch.rpm"
           },
@@ -3835,9 +3816,6 @@
           "sha256:f2ba36563c305ae503625074dc41e6bac19b91846597db7e2d113e30b321800f": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/l/libffi-3.4.2-8.fc36.x86_64.rpm"
           },
-          "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-modules-2.06-28.fc37.noarch.rpm"
-          },
           "sha256:f3550aa734404646dc0d050bbe645960113ac7696f35a99cfcf4d6924a350af0": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/s/systemd-resolved-250.4-2.fc37.x86_64.rpm"
           },
@@ -4524,26 +4502,6 @@
         "checksum": "sha256:5bb35ae3935a110f929aacbe0e1b8c2176072fb9260a1bc1881a4ce8c578cb2e",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-2.06-28.fc37.x86_64.rpm",
-        "checksum": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-modules-2.06-28.fc37.noarch.rpm",
-        "checksum": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
diff --git a/test/data/manifests/fedora_36-x86_64-fedora_iot_commit-boot.json b/test/data/manifests/fedora_36-x86_64-fedora_iot_commit-boot.json
index da907328f..02b71195f 100644
--- a/test/data/manifests/fedora_36-x86_64-fedora_iot_commit-boot.json
+++ b/test/data/manifests/fedora_36-x86_64-fedora_iot_commit-boot.json
@@ -642,22 +642,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:ca6570e80b38f61221d622cab52390fa65a1fe9abfb402cc4ce4990a83f8d0f3",
                     "options": {
@@ -2160,7 +2144,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5755,7 +5739,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -7931,26 +7915,6 @@
         "checksum": "sha256:5bb35ae3935a110f929aacbe0e1b8c2176072fb9260a1bc1881a4ce8c578cb2e",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-2.06-28.fc37.x86_64.rpm",
-        "checksum": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-modules-2.06-28.fc37.noarch.rpm",
-        "checksum": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9802,7 +9766,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_36-x86_64-fedora_iot_commit_debug-boot.json b/test/data/manifests/fedora_36-x86_64-fedora_iot_commit_debug-boot.json
index 35db33804..32a04542f 100644
--- a/test/data/manifests/fedora_36-x86_64-fedora_iot_commit_debug-boot.json
+++ b/test/data/manifests/fedora_36-x86_64-fedora_iot_commit_debug-boot.json
@@ -648,22 +648,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:ca6570e80b38f61221d622cab52390fa65a1fe9abfb402cc4ce4990a83f8d0f3",
                     "options": {
@@ -2166,7 +2150,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5761,7 +5745,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -7937,26 +7921,6 @@
         "checksum": "sha256:5bb35ae3935a110f929aacbe0e1b8c2176072fb9260a1bc1881a4ce8c578cb2e",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-2.06-28.fc37.x86_64.rpm",
-        "checksum": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-modules-2.06-28.fc37.noarch.rpm",
-        "checksum": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -9808,7 +9772,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,
diff --git a/test/data/manifests/fedora_36-x86_64-fedora_iot_container-boot.json b/test/data/manifests/fedora_36-x86_64-fedora_iot_container-boot.json
index 9ddeab088..316349cb2 100644
--- a/test/data/manifests/fedora_36-x86_64-fedora_iot_container-boot.json
+++ b/test/data/manifests/fedora_36-x86_64-fedora_iot_container-boot.json
@@ -642,22 +642,6 @@
                       }
                     }
                   },
-                  {
-                    "id": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
-                  {
-                    "id": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-                    "options": {
-                      "metadata": {
-                        "rpm.check_gpg": true
-                      }
-                    }
-                  },
                   {
                     "id": "sha256:ca6570e80b38f61221d622cab52390fa65a1fe9abfb402cc4ce4990a83f8d0f3",
                     "options": {
@@ -2160,7 +2144,7 @@
         ]
       },
       {
-        "name": "ostree-tree",
+        "name": "os",
         "build": "name:build",
         "stages": [
           {
@@ -5755,7 +5739,7 @@
                 "type": "org.osbuild.tree",
                 "origin": "org.osbuild.pipeline",
                 "references": [
-                  "name:ostree-tree"
+                  "name:os"
                 ]
               }
             },
@@ -9466,26 +9450,6 @@
         "checksum": "sha256:5bb35ae3935a110f929aacbe0e1b8c2176072fb9260a1bc1881a4ce8c578cb2e",
         "check_gpg": true
       },
-      {
-        "name": "grub2-pc",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-2.06-28.fc37.x86_64.rpm",
-        "checksum": "sha256:45d7bed87c83512634879d2519527be7c5184c2d516d146654abeb7d4907c09c",
-        "check_gpg": true
-      },
-      {
-        "name": "grub2-pc-modules",
-        "epoch": 1,
-        "version": "2.06",
-        "release": "28.fc37",
-        "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/public/f36/f36-x86_64-rawhide-20220324/Packages/g/grub2-pc-modules-2.06-28.fc37.noarch.rpm",
-        "checksum": "sha256:f30f4de190e9762199192165577e250d6a1b8936fdd99a7a14646c4154ece10f",
-        "check_gpg": true
-      },
       {
         "name": "grub2-tools",
         "epoch": 1,
@@ -13099,7 +13063,7 @@
         "check_gpg": true
       }
     ],
-    "ostree-tree": [
+    "os": [
       {
         "name": "ModemManager",
         "epoch": 0,