Repository: osbuild/osbuild-composer
Pull request #2800 (2022-07-05 08:40:24)

test/cases: add new mountpoints in filesystem.sh
There are a list of mountpoints in the [documentation](https://www.osbuild.org/guides/blueprint-reference/blueprint-reference.html#filesystem-support), but only
some of this mountpoints are tested in the test filesystem.sh. Add the
ones missing.





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/cases/filesystem.sh b/test/cases/filesystem.sh
index 4fd0a604a..1a25545d6 100644
--- a/test/cases/filesystem.sh
+++ b/test/cases/filesystem.sh
@@ -169,6 +169,26 @@ size = 131072000
 [[customizations.filesystem]]
 mountpoint = "/var/tmp"
 size = 131072000
+
+[[customizations.filesystem]]
+mountpoint = "/home"
+size = 131072000
+
+[[customizations.filesystem]]
+mountpoint = "/opt"
+size = 131072000
+
+[[customizations.filesystem]]
+mountpoint = "/srv"
+size = 131072000
+
+[[customizations.filesystem]]
+mountpoint = "/app"
+size = 131072000
+
+[[customizations.filesystem]]
+mountpoint = "/data"
+size = 131072000
 EOF
 
 build_image "$BLUEPRINT_FILE" rhel85-custom-filesystem qcow2 false
@@ -182,7 +202,7 @@ greenprint "💬 Checking mountpoints"
 INFO="$(sudo /usr/libexec/osbuild-composer-test/image-info "${IMAGE_FILENAME}")"
 FAILED_MOUNTPOINTS=()
 
-for MOUNTPOINT in '/' '/var' '/var/log' '/var/log/audit' '/var/tmp' '/usr' '/tmp'; do
+for MOUNTPOINT in '/' '/var' '/var/log' '/var/log/audit' '/var/tmp' '/usr' '/tmp' '/home' '/opt' '/srv' '/app' '/data'; do
   EXISTS=$(jq -e --arg m "$MOUNTPOINT" 'any(.fstab[] | .[] == $m; .)' <<< "${INFO}")
   if $EXISTS; then
     greenprint "INFO: mountpoint $MOUNTPOINT exists"
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,