Repository: osbuild/osbuild-composer
Pull request #2813 (2022-07-09 20:24:41)

image: introduce an ImageKind abstraction
An ImageKind encapsulates how to generate a manifest for a certain kind of image. Each image type in the distro packages
will be implemented in terms of an image kind, by instantiating its configuration and calling the `InstantiateManifest`
function, which does the actual pipeline creation and instantiation. Different image kinds will have different configuration and
produce different pipelines.

The `osbuild-playground` command is reimplemented in terms of ImageKinds, ideally making it easy to create new ones
and move them over to `internal/image` when satisfied. Note that `osbuild-playground` takes its input as a JSON serialised
struct, but probably anything in `internal/image` would use more high-level objects than that, so might not be a straight
copy-paste.

Furthermore, the fedora image definitions have been ported over to be generated in this way. There is a lot of copying of
struct fields going on in distro/fedora/manifests.go now, and ideally we would not have that indirection but instantiate things directly in the image definitions, at least to a greater extent, so there is a lot of room for future work.

An unsolved problem is how to properly expose the artefact objects from the `InstantiateManifest` function, we probably
want to return a struct of these per image kind, but that might require some rework.

gicmo: This needs a rebase.
teg: Rebased :)

Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 0ca377030..3d41b81cc 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -435,6 +435,36 @@ Rebase OSTree:
       - *fedora_runners
       - *rhel_runners
 
+koji.sh (cloud upload):
+  stage: test
+  extends: .terraform
+  rules:
+    - !reference [.upstream_rules, rules]
+    - !reference [.nightly_rules, rules]
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/koji.sh cloud-upload ${CLOUD_TARGET} ${IMAGE_TYPE}
+  parallel:
+    matrix:
+      - RUNNER:
+          # Brew workers use RHEL-8.6
+          - aws/rhel-8.6-ga-x86_64
+        INTERNAL_NETWORK: ["true"]
+        CLOUD_TARGET: aws
+        IMAGE_TYPE: aws-rhui
+      - RUNNER:
+          # Brew workers use RHEL-8.6
+          - aws/rhel-8.6-ga-x86_64
+        INTERNAL_NETWORK: ["true"]
+        CLOUD_TARGET: azure
+        IMAGE_TYPE: azure-rhui
+      - RUNNER:
+          # Brew workers use RHEL-8.6
+          - aws/rhel-8.6-ga-x86_64
+        INTERNAL_NETWORK: ["true"]
+        CLOUD_TARGET: gcp
+        IMAGE_TYPE: gcp-rhui
+
 koji.sh (cloudapi):
   extends: .integration
   variables:
diff --git a/cmd/osbuild-playground/main.go b/cmd/osbuild-playground/main.go
index f28c74f28..f83ca3960 100644
--- a/cmd/osbuild-playground/main.go
+++ b/cmd/osbuild-playground/main.go
@@ -12,21 +12,13 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
-	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/image"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
-	"github.com/osbuild/osbuild-composer/internal/runner"
 )
 
-var ImageTypes = make(map[string]ImageType)
+var ImageTypes = make(map[string]image.ImageKind)
 
-type ImageType interface {
-	Name() string
-	InstantiateManifest(m *manifest.Manifest, repos []rpmmd.RepoConfig, runner runner.Runner) error
-	GetExports() []string
-	GetCheckpoints() []string
-}
-
-func AddImageType(img ImageType) {
+func AddImageType(img image.ImageKind) {
 	ImageTypes[img.Name()] = img
 }
 
diff --git a/cmd/osbuild-playground/my-container.go b/cmd/osbuild-playground/my-container.go
index 3fb9aa106..6f4ea4ded 100644
--- a/cmd/osbuild-playground/my-container.go
+++ b/cmd/osbuild-playground/my-container.go
@@ -1,6 +1,9 @@
 package main
 
 import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/manifest"
 	"github.com/osbuild/osbuild-composer/internal/platform"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
@@ -36,36 +39,26 @@ func init() {
 // Return nil when you are done, or an error if something
 // went wrong. Your manifest will be streamed to osbuild
 // for building.
-func (img *MyContainer) InstantiateManifest(m *manifest.Manifest, repos []rpmmd.RepoConfig, runner runner.Runner) error {
+func (img *MyContainer) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
 	// Let's create a simple OCI container!
 
 	// configure a build pipeline
 	build := manifest.NewBuild(m, runner, repos)
+	build.Checkpoint()
 
 	// create a minimal non-bootable OS tree
 	os := manifest.NewOS(m, build, &platform.X86{}, repos)
+	os.ExtraBasePackages = []string{"@core"}
+	os.OSCustomizations.Language = "en_US.UTF-8"
+	os.OSCustomizations.Hostname = "my-host"
+	os.OSCustomizations.Timezone = "UTC"
 
 	// create an OCI container containing the OS tree created above
-	manifest.NewOCIContainer(m, build, os)
-
-	return nil
-}
+	container := manifest.NewOCIContainer(m, build, os)
+	artifact := container.Export()
 
-// GetExports returns a list of the pipelines osbuild should export.
-// These are the pipelines containing the artefact you want returned.
-//
-// TODO: Move this to be implemented in terms ofthe Manifest package.
-//       We should not need to know the pipeline names.
-func (img *MyContainer) GetExports() []string {
-	return []string{"container"}
-}
-
-// GetCheckpoints returns a list of the pipelines osbuild should
-// checkpoint. These are the pipelines likely to be reusable in
-// future runs.
-//
-// TODO: Move this to be implemented in terms ofthe Manifest package.
-//       We should not need to know the pipeline names.
-func (img *MyContainer) GetCheckpoints() []string {
-	return []string{"build"}
+	return artifact, nil
 }
diff --git a/cmd/osbuild-playground/my-image.go b/cmd/osbuild-playground/my-image.go
index e87d8dba6..70eb68454 100644
--- a/cmd/osbuild-playground/my-image.go
+++ b/cmd/osbuild-playground/my-image.go
@@ -3,6 +3,7 @@ package main
 import (
 	"math/rand"
 
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/disk"
 	"github.com/osbuild/osbuild-composer/internal/manifest"
 	"github.com/osbuild/osbuild-composer/internal/platform"
@@ -22,11 +23,15 @@ func init() {
 	AddImageType(&MyImage{})
 }
 
-func (img *MyImage) InstantiateManifest(m *manifest.Manifest, repos []rpmmd.RepoConfig, runner runner.Runner) error {
+func (img *MyImage) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
 	// Let's create a simple raw image!
 
 	// configure a build pipeline
 	build := manifest.NewBuild(m, runner, repos)
+	build.Checkpoint()
 
 	// create an x86_64 platform with bios boot
 	platform := &platform.X86{
@@ -34,9 +39,7 @@ func (img *MyImage) InstantiateManifest(m *manifest.Manifest, repos []rpmmd.Repo
 	}
 
 	// TODO: add helper
-	// math/rand is good enough in this case
-	/* #nosec G404 */
-	pt, err := disk.NewPartitionTable(&basePT, nil, 0, false, rand.New(rand.NewSource(0)))
+	pt, err := disk.NewPartitionTable(&basePT, nil, 0, false, rng)
 	if err != nil {
 		panic(err)
 	}
@@ -47,16 +50,8 @@ func (img *MyImage) InstantiateManifest(m *manifest.Manifest, repos []rpmmd.Repo
 	os.KernelName = "kernel" // use the default fedora kernel
 
 	// create a raw image containing the OS tree created above
-	manifest.NewRawImage(m, build, os)
+	raw := manifest.NewRawImage(m, build, os)
+	artifact := raw.Export()
 
-	return nil
-}
-
-// TODO: make internal
-func (img *MyImage) GetExports() []string {
-	return []string{"image"}
-}
-
-func (img *MyImage) GetCheckpoints() []string {
-	return []string{"build"}
+	return artifact, nil
 }
diff --git a/cmd/osbuild-playground/playground.go b/cmd/osbuild-playground/playground.go
index 051edf9be..54faffcc0 100644
--- a/cmd/osbuild-playground/playground.go
+++ b/cmd/osbuild-playground/playground.go
@@ -2,18 +2,20 @@ package main
 
 import (
 	"fmt"
+	"math/rand"
 	"os"
 	"path"
 
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/dnfjson"
+	"github.com/osbuild/osbuild-composer/internal/image"
 	"github.com/osbuild/osbuild-composer/internal/manifest"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/runner"
 )
 
-func RunPlayground(img ImageType, d distro.Distro, arch distro.Arch, repos map[string][]rpmmd.RepoConfig, state_dir string) {
+func RunPlayground(img image.ImageKind, d distro.Distro, arch distro.Arch, repos map[string][]rpmmd.RepoConfig, state_dir string) {
 
 	solver := dnfjson.NewSolver(d.ModulePlatformID(), d.Releasever(), arch.Name(), path.Join(state_dir, "rpmmd"))
 	solver.SetDNFJSONPath(findDnfJsonBin())
@@ -23,8 +25,11 @@ func RunPlayground(img ImageType, d distro.Distro, arch distro.Arch, repos map[s
 
 	manifest := manifest.New()
 
+	/* #nosec G404 */
+	rnd := rand.New(rand.NewSource(0))
+
 	// TODO: query distro for runner
-	err := img.InstantiateManifest(&manifest, repos[arch.Name()], &runner.Fedora{Version: 36})
+	artifact, err := img.InstantiateManifest(&manifest, repos[arch.Name()], &runner.Fedora{Version: 36}, rnd)
 	if err != nil {
 		panic("InstantiateManifest() failed: " + err.Error())
 	}
@@ -50,8 +55,10 @@ func RunPlayground(img ImageType, d distro.Distro, arch distro.Arch, repos map[s
 
 	store := path.Join(state_dir, "osbuild-store")
 
-	_, err = osbuild.RunOSBuild(bytes, store, "./", img.GetExports(), []string{"build"}, false, os.Stdout)
+	_, err = osbuild.RunOSBuild(bytes, store, "./", manifest.GetExports(), manifest.GetCheckpoints(), false, os.Stdout)
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "could not run osbuild: %s", err.Error())
 	}
+
+	fmt.Fprintf(os.Stderr, "built ./%s/%s (%s)\n", artifact.Export(), artifact.Filename(), artifact.MIMEType())
 }
diff --git a/cmd/osbuild-worker/jobimpl-koji-finalize.go b/cmd/osbuild-worker/jobimpl-koji-finalize.go
index b8da7d6b8..723d12d6f 100644
--- a/cmd/osbuild-worker/jobimpl-koji-finalize.go
+++ b/cmd/osbuild-worker/jobimpl-koji-finalize.go
@@ -84,9 +84,34 @@ func (impl *KojiFinalizeJobImpl) kojiFail(server string, buildID int, token stri
 }
 
 func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
-	var args worker.KojiFinalizeJob
-	err := job.Args(&args)
+	logWithId := logrus.WithField("jobId", job.Id().String())
+
+	// initialize the result variable to be used to report status back to composer
+	var kojiFinalizeJobResult = &worker.KojiFinalizeJobResult{}
+	// initialize / declare variables to be used to report information back to Koji
+	var args = &worker.KojiFinalizeJob{}
+	var initArgs *worker.KojiInitJobResult
+
+	// In all cases it is necessary to report result back to osbuild-composer worker API.
+	defer func() {
+		err := job.Update(kojiFinalizeJobResult)
+		if err != nil {
+			logWithId.Errorf("Error reporting job result: %v", err)
+		}
+
+		// Fail the Koji build if the job error is set and the necessary
+		// information to identify the job are available.
+		if kojiFinalizeJobResult.JobError != nil && initArgs != nil {
+			err = impl.kojiFail(args.Server, int(initArgs.BuildID), initArgs.Token)
+			if err != nil {
+				logWithId.Errorf("Failing Koji job failed: %v", err)
+			}
+		}
+	}()
+
+	err := job.Args(args)
 	if err != nil {
+		kojiFinalizeJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingJobArgs, "Error parsing job args", err.Error())
 		return err
 	}
 
@@ -99,30 +124,20 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 		EndTime:   time.Now().Unix(),
 	}
 
-	var initArgs *worker.KojiInitJobResult
 	var buildRoots []koji.BuildRoot
 	var images []koji.Image
 
 	var osbuildResults []worker.OSBuildJobResult
 	initArgs, osbuildResults, err = extractDynamicArgs(job)
 	if err != nil {
+		kojiFinalizeJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, "Error parsing dynamic args", err.Error())
 		return err
 	}
 	build.BuildID = initArgs.BuildID
 
-	// Check the dependencies early. Fail the koji build if any of them failed.
+	// Check the dependencies early.
 	if hasFailedDependency(*initArgs, osbuildResults) {
-		err = impl.kojiFail(args.Server, int(initArgs.BuildID), initArgs.Token)
-
-		// Update the status immediately and bail out.
-		var result worker.KojiFinalizeJobResult
-		if err != nil {
-			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiFailedDependency, err.Error())
-		}
-		err = job.Update(&result)
-		if err != nil {
-			return fmt.Errorf("Error reporting job result: %v", err)
-		}
+		kojiFinalizeJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiFailedDependency, "At least one job dependency failed")
 		return nil
 	}
 
@@ -138,13 +153,15 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 		// multiple
 		buildRPMs = rpmmd.DeduplicateRPMs(buildRPMs)
 
-		// TODO: support multiple upload targets
-		if len(buildArgs.TargetResults) != 1 {
-			// TODO: should we call kojiFail() and update job status, instead of just returning?
-			return fmt.Errorf("error: Koji compose OSBuild job result doesn't contain exactly one target result")
+		kojiTargetResults := buildArgs.TargetResultsByName(target.TargetNameKoji)
+		// Only a single Koji target is allowed per osbuild job
+		if len(kojiTargetResults) != 1 {
+			kojiFinalizeJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiFinalize, "Exactly one Koji target results are expected per osbuild job")
+			return nil
 		}
-		kojiTarget := buildArgs.TargetResults[0]
-		kojiTargetOptions := kojiTarget.Options.(*target.KojiTargetResultOptions)
+
+		kojiTargetResult := kojiTargetResults[0]
+		kojiTargetOptions := kojiTargetResult.Options.(*target.KojiTargetResultOptions)
 
 		buildRoots = append(buildRoots, koji.BuildRoot{
 			ID: uint64(i),
@@ -195,11 +212,7 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 	err = impl.kojiImport(args.Server, build, buildRoots, images, args.KojiDirectory, initArgs.Token)
 	if err != nil {
 		result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiFinalize, err.Error())
-	}
-
-	err = job.Update(&result)
-	if err != nil {
-		return fmt.Errorf("Error reporting job result: %v", err)
+		return err
 	}
 
 	return nil
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 25185c168..2733291a0 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -9,6 +9,7 @@ import (
 	"math/big"
 	"net/url"
 	"os"
+	"os/exec"
 	"path"
 	"strings"
 
@@ -418,7 +419,22 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			if impl.AWSBucket != "" {
 				bucket = impl.AWSBucket
 			}
-			_, err = a.Upload(path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename), bucket, key)
+
+			// TODO: Remove this once multiple exports will be supported and used by image definitions
+			// RHUI images tend to be produced as archives in Brew to save disk space,
+			// however they can't be imported to the cloud provider as an archive.
+			// Workaround this situation for Koji composes by checking if the image file
+			// is an archive and if it is, extract it before uploading to the cloud.
+			imagePath := path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename)
+			if strings.HasSuffix(imagePath, ".xz") {
+				imagePath, err = extractXzArchive(imagePath)
+				if err != nil {
+					targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorTargetError, "Failed to extract compressed image", err.Error())
+					break
+				}
+			}
+
+			_, err = a.Upload(imagePath, bucket, key)
 			if err != nil {
 				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
 				break
@@ -614,6 +630,20 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			// Azure cannot create an image from a blob without .vhd extension
 			blobName := azure.EnsureVHDExtension(jobTarget.ImageName)
 
+			// TODO: Remove this once multiple exports will be supported and used by image definitions
+			// RHUI images tend to be produced as archives in Brew to save disk space,
+			// however they can't be imported to the cloud provider as an archive.
+			// Workaround this situation for Koji composes by checking if the image file
+			// is an archive and if it is, extract it before uploading to the cloud.
+			imagePath := path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename)
+			if strings.HasSuffix(imagePath, ".xz") {
+				imagePath, err = extractXzArchive(imagePath)
+				if err != nil {
+					targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorTargetError, "Failed to extract compressed image", err.Error())
+					break
+				}
+			}
+
 			logWithId.Info("[Azure] ⬆ Uploading the image")
 			err = azureStorageClient.UploadPageBlob(
 				azure.BlobMetadata{
@@ -621,7 +651,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 					ContainerName:  storageContainer,
 					BlobName:       blobName,
 				},
-				path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename),
+				imagePath,
 				azure.DefaultUploadThreads,
 			)
 			if err != nil {
@@ -790,3 +820,19 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 
 	return nil
 }
+
+// extractXzArchive extracts the provided XZ archive in the same directory
+// and returns the path to decompressed file.
+func extractXzArchive(archivePath string) (string, error) {
+	workingDir, archiveFilename := path.Split(archivePath)
+	decompressedFilename := strings.TrimSuffix(archiveFilename, ".xz")
+
+	cmd := exec.Command("xz", "-d", archivePath)
+	cmd.Dir = workingDir
+	err := cmd.Run()
+	if err != nil {
+		return "", err
+	}
+
+	return path.Join(workingDir, decompressedFilename), nil
+}
diff --git a/internal/artifact/artifact.go b/internal/artifact/artifact.go
new file mode 100644
index 000000000..451b6a0cb
--- /dev/null
+++ b/internal/artifact/artifact.go
@@ -0,0 +1,31 @@
+package artifact
+
+type Artifact struct {
+	export   string
+	filename string
+	mimeType string
+}
+
+func New(export, filename string, mimeType *string) *Artifact {
+	artifact := &Artifact{
+		export:   export,
+		filename: filename,
+		mimeType: "application/octet-stream",
+	}
+	if mimeType != nil {
+		artifact.mimeType = *mimeType
+	}
+	return artifact
+}
+
+func (a *Artifact) Export() string {
+	return a.export
+}
+
+func (a *Artifact) Filename() string {
+	return a.filename
+}
+
+func (a *Artifact) MIMEType() string {
+	return a.mimeType
+}
diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go
index 10c2633a2..111f99717 100644
--- a/internal/cloudapi/v2/handler.go
+++ b/internal/cloudapi/v2/handler.go
@@ -299,10 +299,6 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				return HTTPError(ErrorJSONUnMarshallingError)
 			}
 		} else {
-			// TODO: support uploads also for koji
-			if request.Koji != nil {
-				return HTTPError(ErrorJSONUnMarshallingError)
-			}
 			/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
 			switch ir.ImageType {
 			case ImageTypesAws:
@@ -371,6 +367,8 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 
 				irTarget = t
 			case ImageTypesGcp:
+				fallthrough
+			case ImageTypesGcpRhui:
 				var gcpUploadOptions GCPUploadOptions
 				jsonUploadOptions, err := json.Marshal(*ir.UploadOptions)
 				if err != nil {
@@ -485,6 +483,8 @@ func imageTypeFromApiImageType(it ImageTypes, arch distro.Arch) string {
 		return "ec2-sap"
 	case ImageTypesGcp:
 		return "gce"
+	case ImageTypesGcpRhui:
+		return "gce-rhui"
 	case ImageTypesAzure:
 		return "vhd"
 	case ImageTypesAzureRhui:
@@ -512,6 +512,53 @@ func imageTypeFromApiImageType(it ImageTypes, arch distro.Arch) string {
 	return ""
 }
 
+func targetResultToUploadStatus(t *target.TargetResult) (*UploadStatus, error) {
+	var us *UploadStatus
+	var uploadType UploadTypes
+	var uploadOptions interface{}
+
+	switch t.Name {
+	case target.TargetNameAWS:
+		uploadType = UploadTypesAws
+		awsOptions := t.Options.(*target.AWSTargetResultOptions)
+		uploadOptions = AWSEC2UploadStatus{
+			Ami:    awsOptions.Ami,
+			Region: awsOptions.Region,
+		}
+	case target.TargetNameAWSS3:
+		uploadType = UploadTypesAwsS3
+		awsOptions := t.Options.(*target.AWSS3TargetResultOptions)
+		uploadOptions = AWSS3UploadStatus{
+			Url: awsOptions.URL,
+		}
+	case target.TargetNameGCP:
+		uploadType = UploadTypesGcp
+		gcpOptions := t.Options.(*target.GCPTargetResultOptions)
+		uploadOptions = GCPUploadStatus{
+			ImageName: gcpOptions.ImageName,
+			ProjectId: gcpOptions.ProjectID,
+		}
+	case target.TargetNameAzureImage:
+		uploadType = UploadTypesAzure
+		gcpOptions := t.Options.(*target.AzureImageTargetResultOptions)
+		uploadOptions = AzureUploadStatus{
+			ImageName: gcpOptions.ImageName,
+		}
+	default:
+		return nil, fmt.Errorf("unknown upload target: %s", t.Name)
+	}
+
+	us = &UploadStatus{
+		// TODO: determine upload status based on the target results, not job results
+		// Don't set the status here for now, but let it be set by the caller.
+		//Status:  UploadStatusValue(result.UploadStatus),
+		Type:    uploadType,
+		Options: uploadOptions,
+	}
+
+	return us, nil
+}
+
 func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
 	return h.server.EnsureJobChannel(h.getComposeStatusImpl)(ctx, id)
 }
@@ -545,47 +592,13 @@ func (h *apiHandlers) getComposeStatusImpl(ctx echo.Context, id string) error {
 			if len(result.TargetResults) != 1 {
 				return HTTPError(ErrorSeveralUploadTargets)
 			}
-			tr := *result.TargetResults[0]
-
-			var uploadType UploadTypes
-			var uploadOptions interface{}
-
-			switch tr.Name {
-			case target.TargetNameAWS:
-				uploadType = UploadTypesAws
-				awsOptions := tr.Options.(*target.AWSTargetResultOptions)
-				uploadOptions = AWSEC2UploadStatus{
-					Ami:    awsOptions.Ami,
-					Region: awsOptions.Region,
-				}
-			case target.TargetNameAWSS3:
-				uploadType = UploadTypesAwsS3
-				awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
-				uploadOptions = AWSS3UploadStatus{
-					Url: awsOptions.URL,
-				}
-			case target.TargetNameGCP:
-				uploadType = UploadTypesGcp
-				gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
-				uploadOptions = GCPUploadStatus{
-					ImageName: gcpOptions.ImageName,
-					ProjectId: gcpOptions.ProjectID,
-				}
-			case target.TargetNameAzureImage:
-				uploadType = UploadTypesAzure
-				gcpOptions := tr.Options.(*target.AzureImageTargetResultOptions)
-				uploadOptions = AzureUploadStatus{
-					ImageName: gcpOptions.ImageName,
-				}
-			default:
+			tr := result.TargetResults[0]
+			us, err = targetResultToUploadStatus(tr)
+			if err != nil {
 				return HTTPError(ErrorUnknownUploadTarget)
 			}
-
-			us = &UploadStatus{
-				Status:  UploadStatusValue(result.UploadStatus),
-				Type:    uploadType,
-				Options: uploadOptions,
-			}
+			// TODO: determine upload status based on the target results, not job results
+			us.Status = UploadStatusValue(result.UploadStatus)
 		}
 
 		return ctx.JSON(http.StatusOK, ComposeStatus{
@@ -627,10 +640,28 @@ func (h *apiHandlers) getComposeStatusImpl(ctx echo.Context, id string) error {
 			if err != nil {
 				return HTTPError(ErrorGettingBuildDependencyStatus)
 			}
+
+			var us *UploadStatus
+			// only single upload target in addition to Koji is allowed
+			if len(buildJobResult.TargetResults) > 2 {
+				return HTTPError(ErrorSeveralUploadTargets)
+			}
+			for _, tr := range buildJobResult.TargetResults {
+				if tr.Name != target.TargetNameKoji {
+					us, err = targetResultToUploadStatus(tr)
+					if err != nil {
+						return HTTPError(ErrorUnknownUploadTarget)
+					}
+					// TODO: determine upload status based on the target results, not job results
+					us.Status = UploadStatusValue(buildJobResult.UploadStatus)
+				}
+			}
+
 			buildJobResults = append(buildJobResults, buildJobResult)
 			buildJobStatuses = append(buildJobStatuses, ImageStatus{
-				Status: imageStatusFromKojiJobStatus(buildJobStatus, &initResult, &buildJobResult),
-				Error:  composeStatusErrorFromJobError(buildJobError),
+				Status:       imageStatusFromKojiJobStatus(buildJobStatus, &initResult, &buildJobResult),
+				Error:        composeStatusErrorFromJobError(buildJobError),
+				UploadStatus: us,
 			})
 		}
 		response := ComposeStatus{
diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go
index cf4d2b358..4a5e4e65b 100644
--- a/internal/cloudapi/v2/openapi.v2.gen.go
+++ b/internal/cloudapi/v2/openapi.v2.gen.go
@@ -68,6 +68,8 @@ const (
 
 	ImageTypesGcp ImageTypes = "gcp"
 
+	ImageTypesGcpRhui ImageTypes = "gcp-rhui"
+
 	ImageTypesGuestImage ImageTypes = "guest-image"
 
 	ImageTypesImageInstaller ImageTypes = "image-installer"
@@ -712,68 +714,68 @@ var swaggerSpec = []string{
 	"r+WL2fynEcMGuORnqeytVM5+bZbpNhZIF/IIvUXaolJ6KRUO+/ng8REZkcHSQyqPEWQQP5tz0x/IUWrF",
 	"2yfbX3A2C7z9C/WOyt9tx1p75eVN1m1xZYf0H9EuHJIoFJ0bjs7vrMLfL+e3wszQihXHAdjSiANppZ1l",
 	"fillIzUSO+GfAWXB31HRNczr7MnihoRtoIJziQbOeYrZPg7/tOHmLw691c+3gJig/ho+RIaFUqvsdfhL",
-	"+WrEogeYcAEdRz2wdE/+X2rZygyof7dGzbgnw7XYpbTDnOq2bOzbmQtkUAZTdRmKpWqQHwgeHSRfbc3M",
-	"aTlNO9XKaS02IEJshtj2jCjum9IJTpsKcWh70pRZ6rHtj7eKVAzHVlQhn+5av0IuGZMvnCHG97Lj+c9b",
-	"LULy16jC5p81xDVX4uzkqqAUY/Cl+wyznEQVP/YOmkSJSTjyEPhD+q9k/xjuxCUdosP3NsgpJvG5gKiH",
-	"a5/x0YF3/42gAjpxr3a4oJAmV81fQc9VMDl58CyeTIQWf28NHmSIxJxl65DI0wrCKhkIwxoT+CNk3RnQ",
-	"ciWtMM4ZsIROi4WxkS+MK+NKDlbyRVSE5bKRG5c004TfkzKGgmDMINHtlIOnCLAoHbMBj9nIyVQygUfM",
-	"SNX/vnPE2B8Rr5TmftHn82kH24D2ObmTUdpjqR2SsO/T46XlgBjFpe7DzVcY4nZ5tyIXG4rEEoE8euBN",
-	"ZBw/Mn/7dg5brlE89IrAKBQ6EFrGvNgwWZ90SQTRwUG7lAyYsKJRutSNgGbfcECOQunYt9m6QdIMGTYM",
-	"Wkik90JEZAzMRUYKXmUteRIO5RnKM0eYct1G+vTF8qyN9Y4pdRBUyVXLs6Zoua+1l7eXYIqWq5qB5PW6",
-	"DqFSN5ivKxXL7QRUSv5Xa1w2r8Ht5S24va91mnXQbgxBrXNTb6vXIzIi7l3zunZZ1fs6rTWq5x2zMrya",
-	"ordWCRpOdzgvw8vLptOCjqi0JrlFppZrn9hNs+kvLoX3MCmjEen0rPP7cmkCB0Xv4bzoXnRbeW+KCOpl",
-	"9IH7+no3vV7ecfspR++e5o23+/44W7/u1s36pTV9qtzlRuTtecqaep1daHe5OWuPHegb9v0JfoCkes7d",
-	"bGXYeOXjYvU+XzbEPevm74bGo3XaO3nCt+ZDpTci7dpkoOVnD7Ubo9vnw/xpB9ZJqellb2ZepdmgmSZq",
-	"PAyzr2795rYK29q4dZX3TatQ99GUnwz6IzK/exygemfhP3dKN90nenPbns+6d+ZibGWfzisz/1lri0lG",
-	"v77KLaCvLVxe9U+vWh6azm5uewtnRJavYrJ8Nhl9wOhi6c2frdndXBDSrWSsfsPPtB4GbKgVc27jflCu",
-	"6+NyYapfXQwuzO7UIdPLzIho5n2h2oNFrXCVX0y0qRij/Kyt3z7R2xu/XXvgV/2Zpt1fDqvLW+QvTypl",
-	"/T4zbNjd8jTff2hPRqSEms/WEndvtLmTHV6e99q678yn/LR64jtTK0sH4wLPv7nPs1utfEkHi8dCbgLb",
-	"xcf+ybX9jNCIVEraE32wx3q27fVPJuYznXDWEM+V2/H988lwdlHpecx4rLLJ1bg1zbW8Xru6GNgLflfl",
-	"NfsyOyJax1/kHmG3plm5ZvFW7xqtjP46oVpF19mk9uTjxSPDReyfdp+8yusgY/bfrl1uNC1Sybw+t0cE",
-	"V+58x/TLZf/VfszMRW4sCBZWj79O7EXXnwzvC8/jgj0VFxW7fZ95eioXcq92p9ieV3vVu2ptRMT5xeXz",
-	"Y2+muw2rfd7NtvvVyrP7MB3nW3Zn0M12nmpL+Ji1deJUo+f6VWsG3YeJUS/ORkR39RN817qp1bq1erVa",
-	"uMCNBroqucy+uCr7D/yu0+3mtGFRf7bJYli5qLpKh+qX88pFfT5tjkht3ry8uKOtepXXa7VhvTpv1K+s",
-	"Rv2iUK3WrendevbJ9bCaKdeGnuUs+9Xn4ZU9WbbtEcmcmKW3W/NhNr7KaY3X/LRZvrmoXWuk83RSu8+6",
-	"/qx/8jrw+/nHDqvl3fyl7wiv3Wu02h3hFhvnI5Jll29PVTrILr3TYbPSqZ4b3Xr9ZjmpTjh9vK+Uh/d+",
-	"/SQzJhM2QL1cp3dTN5e39XLp8bRSxDcPI+IW+ydjfnc+L9dzHeYY1W6he+7T5XO2j8UlfC607zoP4mTQ",
-	"gNkC5sP+ZX3yRsu3w8pDvnUzLWojYr0+WpXcdWbs5hpv/fKgkn9snI+zzmxSaDqzhdV8bSMrm317Gi5c",
-	"Nuw/t1p1c/ZmnjjX/ZK/sK5GZLLItLSl85zr4PElK11Wq8ub0/tHVn3uz/tdraFPBpV5o04W0/65v3x1",
-	"H+cPs+vak99oPlRuUH44Il18nzVb1xVulM89frEodk+eDNIld/2TKzYZ3LbP8+4jc6oGaQxsY/hQmTxP",
-	"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",
-	"o5hIonvSKzSfwfnx/n8DAAD//z1YbXMTWwAA",
+	"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",
 }
 
 // GetSwagger returns the content of the embedded swagger specification file
diff --git a/internal/cloudapi/v2/openapi.v2.yml b/internal/cloudapi/v2/openapi.v2.yml
index 7b9a59060..3c6d58997 100644
--- a/internal/cloudapi/v2/openapi.v2.yml
+++ b/internal/cloudapi/v2/openapi.v2.yml
@@ -655,6 +655,7 @@ components:
         - edge-container
         - edge-installer
         - gcp
+        - gcp-rhui
         - guest-image
         - image-installer
         - vsphere
diff --git a/internal/cloudapi/v2/server.go b/internal/cloudapi/v2/server.go
index b2ddba870..4d7a6671e 100644
--- a/internal/cloudapi/v2/server.go
+++ b/internal/cloudapi/v2/server.go
@@ -185,12 +185,18 @@ func (s *Server) enqueueKojiCompose(taskID uint64, server, name, version, releas
 		kojiTarget.OsbuildArtifact.ExportName = ir.imageType.Exports()[0]
 		kojiTarget.ImageName = kojiFilename
 
+		targets := []*target.Target{kojiTarget}
+		// add any cloud upload target if defined
+		if ir.target != nil {
+			targets = append(targets, ir.target)
+		}
+
 		buildID, err := s.workers.EnqueueOSBuildAsDependency(ir.arch.Name(), &worker.OSBuildJob{
 			PipelineNames: &worker.PipelineNames{
 				Build:   ir.imageType.BuildPipelines(),
 				Payload: ir.imageType.PayloadPipelines(),
 			},
-			Targets:            []*target.Target{kojiTarget},
+			Targets:            targets,
 			ManifestDynArgsIdx: common.IntToPtr(1),
 		}, []uuid.UUID{initID, manifestJobID}, channel)
 		if err != nil {
diff --git a/internal/distro/fedora/distro.go b/internal/distro/fedora/distro.go
index 5ee0a0368..3d739b6e6 100644
--- a/internal/distro/fedora/distro.go
+++ b/internal/distro/fedora/distro.go
@@ -12,6 +12,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/disk"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/environment"
+	"github.com/osbuild/osbuild-composer/internal/image"
 	"github.com/osbuild/osbuild-composer/internal/manifest"
 	"github.com/osbuild/osbuild-composer/internal/platform"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
@@ -81,7 +82,7 @@ var (
 			EnabledServices: iotServices,
 		},
 		rpmOstree:        true,
-		manifest:         iotCommitManifest,
+		image:            iotCommitImage,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"os", "ostree-commit", "commit-archive"},
 		exports:          []string{"commit-archive"},
@@ -103,7 +104,7 @@ var (
 		},
 		rpmOstree:        true,
 		bootISO:          false,
-		manifest:         iotContainerManifest,
+		image:            iotContainerImage,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"ostree-tree", "ostree-commit", "container-tree", "container"},
 		exports:          []string{"container"},
@@ -123,7 +124,7 @@ var (
 		},
 		rpmOstree:        true,
 		bootISO:          true,
-		manifest:         iotInstallerManifest,
+		image:            iotInstallerImage,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"anaconda-tree", "bootiso-tree", "bootiso"},
 		exports:          []string{"bootiso"},
@@ -148,7 +149,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		manifest:            qcow2Manifest,
+		image:               liveImage,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "qcow2"},
 		exports:             []string{"qcow2"},
@@ -176,7 +177,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		manifest:            vhdManifest,
+		image:               liveImage,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "vpc"},
 		exports:             []string{"vpc"},
@@ -203,7 +204,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		manifest:            vmdkManifest,
+		image:               liveImage,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "vmdk"},
 		exports:             []string{"vmdk"},
@@ -229,7 +230,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         2 * GigaByte,
-		manifest:            openstackManifest,
+		image:               liveImage,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image", "qcow2"},
 		exports:             []string{"qcow2"},
@@ -252,7 +253,7 @@ var (
 		kernelOptions:       defaultKernelOptions,
 		bootable:            true,
 		defaultSize:         6 * GigaByte,
-		manifest:            ec2Manifest,
+		image:               liveImage,
 		buildPipelines:      []string{"build"},
 		payloadPipelines:    []string{"os", "image"},
 		exports:             []string{"image"},
@@ -273,7 +274,7 @@ var (
 			Locale:      "C.UTF-8",
 			Timezone:    "Etc/UTC",
 		},
-		manifest:         containerManifest,
+		image:            containerImage,
 		bootable:         false,
 		buildPipelines:   []string{"build"},
 		payloadPipelines: []string{"os", "container"},
@@ -287,7 +288,6 @@ type distribution struct {
 	osVersion          string
 	releaseVersion     string
 	modulePlatformID   string
-	vendor             string
 	ostreeRefTmpl      string
 	isolabelTmpl       string
 	runner             runner.Runner
@@ -309,7 +309,6 @@ var distroMap = map[string]distribution{
 		osVersion:          "35",
 		releaseVersion:     "35",
 		modulePlatformID:   "platform:f35",
-		vendor:             "fedora",
 		ostreeRefTmpl:      "fedora/35/%s/iot",
 		isolabelTmpl:       "Fedora-35-BaseOS-%s",
 		runner:             &runner.Fedora{Version: 35},
@@ -321,7 +320,6 @@ var distroMap = map[string]distribution{
 		osVersion:          "36",
 		releaseVersion:     "36",
 		modulePlatformID:   "platform:f36",
-		vendor:             "fedora",
 		ostreeRefTmpl:      "fedora/36/%s/iot",
 		isolabelTmpl:       "Fedora-36-BaseOS-%s",
 		runner:             &runner.Fedora{Version: 36},
@@ -439,7 +437,7 @@ func (a *architecture) Distro() distro.Distro {
 	return a.distro
 }
 
-type manifestFunc func(m *manifest.Manifest, workload workload.Workload, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, packageSets map[string]rpmmd.PackageSet, rng *rand.Rand) error
+type imageFunc func(workload workload.Workload, t *imageType, customizations *blueprint.Customizations, options distro.ImageOptions, packageSets map[string]rpmmd.PackageSet, rng *rand.Rand) (image.ImageKind, error)
 
 type packageSetFunc func(t *imageType) rpmmd.PackageSet
 
@@ -458,7 +456,7 @@ type imageType struct {
 	buildPipelines     []string
 	payloadPipelines   []string
 	exports            []string
-	manifest           manifestFunc
+	image              imageFunc
 
 	// bootISO: installable ISO
 	bootISO bool
@@ -631,13 +629,16 @@ func (t *imageType) initializeManifest(bp *blueprint.Blueprint,
 	/* #nosec G404 */
 	rng := rand.New(source)
 
+	img, err := t.image(w, t, bp.Customizations, options, packageSets, rng)
+	if err != nil {
+		return nil, err
+	}
 	manifest := manifest.New()
-	err := t.manifest(&manifest, w, t, bp.Customizations, options, repos, packageSets, rng)
+	_, err = img.InstantiateManifest(&manifest, repos, t.arch.distro.runner, rng)
 	if err != nil {
 		return nil, err
 	}
-
-	return &manifest, nil
+	return &manifest, err
 }
 
 func (t *imageType) Manifest(customizations *blueprint.Customizations,
@@ -745,16 +746,50 @@ func newDistro(distroName string) distro.Distro {
 		&platform.X86{
 			BIOS:       true,
 			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_QCOW2,
+				QCOW2Compat: "1.1",
+			},
 		},
 		qcow2ImgType,
+		ociImgType,
+	)
+	x86_64.addImageTypes(
+		&platform.X86{
+			BIOS:       true,
+			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_QCOW2,
+			},
+		},
 		openstackImgType,
+	)
+	x86_64.addImageTypes(
+		&platform.X86{
+			BIOS:       true,
+			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_VHD,
+			},
+		},
 		vhdImgType,
+	)
+	x86_64.addImageTypes(
+		&platform.X86{
+			BIOS:       true,
+			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_VMDK,
+			},
+		},
 		vmdkImgType,
-		ociImgType,
 	)
 	x86_64.addImageTypes(
 		&platform.X86{
 			BIOS: true,
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_RAW,
+			},
 		},
 		amiImgType,
 	)
@@ -790,12 +825,32 @@ func newDistro(distroName string) distro.Distro {
 	aarch64.addImageTypes(
 		&platform.Aarch64{
 			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_RAW,
+			},
 		},
 		amiImgType,
+	)
+	aarch64.addImageTypes(
+		&platform.Aarch64{
+			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_QCOW2,
+				QCOW2Compat: "1.1",
+			},
+		},
 		qcow2ImgType,
-		openstackImgType,
 		ociImgType,
 	)
+	aarch64.addImageTypes(
+		&platform.Aarch64{
+			UEFIVendor: "fedora",
+			BasePlatform: platform.BasePlatform{
+				ImageFormat: platform.FORMAT_QCOW2,
+			},
+		},
+		openstackImgType,
+	)
 	aarch64.addImageTypes(
 		&platform.Aarch64{},
 		containerImgType,
diff --git a/internal/distro/fedora/images.go b/internal/distro/fedora/images.go
new file mode 100644
index 000000000..63c9b2c84
--- /dev/null
+++ b/internal/distro/fedora/images.go
@@ -0,0 +1,257 @@
+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/image"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/workload"
+)
+
+// HELPERS
+
+func osCustomizations(
+	t *imageType,
+	osPackageSet rpmmd.PackageSet,
+	c *blueprint.Customizations) manifest.OSCustomizations {
+
+	imageConfig := t.getDefaultImageConfig()
+
+	osc := manifest.OSCustomizations{}
+
+	if t.bootable || t.rpmOstree {
+		osc.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)
+		}
+		osc.KernelOptionsAppend = kernelOptions
+	}
+
+	osc.ExtraBasePackages = osPackageSet.Include
+	osc.ExcludeBasePackages = osPackageSet.Exclude
+	osc.ExtraBaseRepos = osPackageSet.Repositories
+
+	osc.GPGKeyFiles = imageConfig.GPGKeyFiles
+	osc.ExcludeDocs = imageConfig.ExcludeDocs
+
+	if !t.bootISO {
+		// don't put users and groups in the payload of an installer
+		// add them via kickstart instead
+		osc.Groups = c.GetGroups()
+		osc.Users = c.GetUsers()
+	}
+
+	osc.EnabledServices = imageConfig.EnabledServices
+	osc.DisabledServices = imageConfig.DisabledServices
+	osc.DefaultTarget = imageConfig.DefaultTarget
+
+	osc.Firewall = c.GetFirewall()
+
+	language, keyboard := c.GetPrimaryLocale()
+	if language != nil {
+		osc.Language = *language
+	} else {
+		osc.Language = imageConfig.Locale
+	}
+	if keyboard != nil {
+		osc.Keyboard = keyboard
+	} else if imageConfig.Keyboard != nil {
+		osc.Keyboard = &imageConfig.Keyboard.Keymap
+	}
+
+	if hostname := c.GetHostname(); hostname != nil {
+		osc.Hostname = *hostname
+	} else {
+		osc.Hostname = "localhost.localdomain"
+	}
+
+	timezone, ntpServers := c.GetTimezoneSettings()
+	if timezone != nil {
+		osc.Timezone = *timezone
+	} else {
+		osc.Timezone = imageConfig.Timezone
+	}
+
+	if len(ntpServers) > 0 {
+		osc.NTPServers = ntpServers
+	} else if imageConfig.TimeSynchronization != nil {
+		osc.NTPServers = imageConfig.TimeSynchronization.Timeservers
+	}
+
+	if !imageConfig.NoSElinux {
+		osc.SElinux = "targeted"
+	}
+
+	osc.Grub2Config = imageConfig.Grub2Config
+	osc.Sysconfig = imageConfig.Sysconfig
+	osc.SystemdLogind = imageConfig.SystemdLogind
+	osc.CloudInit = imageConfig.CloudInit
+	osc.Modprobe = imageConfig.Modprobe
+	osc.DracutConf = imageConfig.DracutConf
+	osc.SystemdUnit = imageConfig.SystemdUnit
+	osc.Authselect = imageConfig.Authselect
+	osc.SELinuxConfig = imageConfig.SELinuxConfig
+	osc.Tuned = imageConfig.Tuned
+	osc.Tmpfilesd = imageConfig.Tmpfilesd
+	osc.PamLimitsConf = imageConfig.PamLimitsConf
+	osc.Sysctld = imageConfig.Sysctld
+	osc.DNFConfig = imageConfig.DNFConfig
+	osc.SshdConfig = imageConfig.SshdConfig
+	osc.AuthConfig = imageConfig.Authconfig
+	osc.PwQuality = imageConfig.PwQuality
+
+	return osc
+}
+
+// IMAGES
+
+func liveImage(workload workload.Workload,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	packageSets map[string]rpmmd.PackageSet,
+	rng *rand.Rand) (image.ImageKind, error) {
+
+	img := image.NewLiveImage()
+	img.Platform = t.platform
+	img.OSCustomizations = osCustomizations(t, packageSets[osPkgsKey], customizations)
+	img.Environment = t.environment
+	img.Workload = workload
+	// TODO: move generation into LiveImage
+	pt, err := t.getPartitionTable(customizations.GetFilesystems(), options, rng)
+	if err != nil {
+		return nil, err
+	}
+	img.PartitionTable = pt
+
+	img.Filename = t.Filename()
+
+	return img, nil
+}
+
+func containerImage(workload workload.Workload,
+	t *imageType,
+	c *blueprint.Customizations,
+	options distro.ImageOptions,
+	packageSets map[string]rpmmd.PackageSet,
+	rng *rand.Rand) (image.ImageKind, error) {
+	img := image.NewBaseContainer()
+
+	img.Platform = t.platform
+	img.OSCustomizations = osCustomizations(t, packageSets[osPkgsKey], c)
+	img.Environment = t.environment
+	img.Workload = workload
+
+	img.Filename = t.Filename()
+
+	return img, nil
+}
+
+func iotCommitImage(workload workload.Workload,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	packageSets map[string]rpmmd.PackageSet,
+	rng *rand.Rand) (image.ImageKind, error) {
+
+	img := image.NewOSTreeArchive()
+
+	img.Platform = t.platform
+	img.OSCustomizations = osCustomizations(t, packageSets[osPkgsKey], customizations)
+	img.Environment = t.environment
+	img.Workload = workload
+
+	var parent *manifest.OSTreeParent
+	if options.OSTree.Parent != "" && options.OSTree.URL != "" {
+		parent = &manifest.OSTreeParent{
+			Checksum: options.OSTree.Parent,
+			URL:      options.OSTree.URL,
+		}
+	}
+	img.OSTreeParent = manifest.OSTree{
+		Parent: parent,
+	}
+
+	img.OSTreeRef = options.OSTree.Ref
+	img.OSVersion = t.arch.distro.osVersion
+
+	img.Filename = t.Filename()
+
+	return img, nil
+}
+
+func iotContainerImage(workload workload.Workload,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	packageSets map[string]rpmmd.PackageSet,
+	rng *rand.Rand) (image.ImageKind, error) {
+
+	img := image.NewOSTreeContainer()
+
+	img.Platform = t.platform
+	img.OSCustomizations = osCustomizations(t, packageSets[osPkgsKey], customizations)
+	img.ContainerLanguage = img.OSCustomizations.Language
+	img.Environment = t.environment
+	img.Workload = workload
+
+	var parent *manifest.OSTreeParent
+	if options.OSTree.Parent != "" && options.OSTree.URL != "" {
+		parent = &manifest.OSTreeParent{
+			Checksum: options.OSTree.Parent,
+			URL:      options.OSTree.URL,
+		}
+	}
+	img.OSTreeParent = manifest.OSTree{
+		Parent: parent,
+	}
+
+	img.OSTreeRef = options.OSTree.Ref
+	img.OSVersion = t.arch.distro.osVersion
+
+	img.ExtraContainerPackages = packageSets[containerPkgsKey]
+
+	img.Filename = t.Filename()
+
+	return img, nil
+}
+
+func iotInstallerImage(workload workload.Workload,
+	t *imageType,
+	customizations *blueprint.Customizations,
+	options distro.ImageOptions,
+	packageSets map[string]rpmmd.PackageSet,
+	rng *rand.Rand) (image.ImageKind, error) {
+
+	d := t.arch.distro
+
+	img := image.NewOSTreeInstaller()
+
+	img.Platform = t.platform
+	img.ExtraBasePackages = packageSets[installerPkgsKey]
+	img.Users = customizations.GetUsers()
+	img.Groups = customizations.GetGroups()
+
+	img.ISOLabelTempl = d.isolabelTmpl
+	img.Product = d.product
+	img.Variant = "IoT"
+	img.OSName = "fedora"
+	img.OSVersion = d.osVersion
+	img.Release = "202010217.n.0" // ???
+
+	img.OSTreeURL = options.OSTree.URL
+	img.OSTreeRef = options.OSTree.Ref
+	img.OSTreeCommit = options.OSTree.Parent
+
+	img.Filename = t.Filename()
+
+	return img, nil
+}
diff --git a/internal/distro/fedora/manifests.go b/internal/distro/fedora/manifests.go
deleted file mode 100644
index 381e9e2d2..000000000
--- a/internal/distro/fedora/manifests.go
+++ /dev/null
@@ -1,461 +0,0 @@
-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/platform"
-	"github.com/osbuild/osbuild-composer/internal/rpmmd"
-	"github.com/osbuild/osbuild-composer/internal/workload"
-)
-
-func qcow2Manifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return err
-	}
-	imagePipeline := manifest.NewRawImage(m, buildPipeline, treePipeline)
-	qcow2Pipeline := manifest.NewQCOW2(m, buildPipeline, imagePipeline)
-	qcow2Pipeline.Filename = t.filename
-	qcow2Pipeline.Compat = "1.1"
-
-	return nil
-}
-
-func vhdManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return err
-	}
-	imagePipeline := manifest.NewRawImage(m, buildPipeline, treePipeline)
-	p := manifest.NewVPC(m, buildPipeline, imagePipeline)
-	p.Filename = t.filename
-
-	return nil
-}
-
-func vmdkManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return err
-	}
-	imagePipeline := manifest.NewRawImage(m, buildPipeline, treePipeline)
-	p := manifest.NewVMDK(m, buildPipeline, imagePipeline)
-	p.Filename = t.filename
-
-	return nil
-}
-
-func openstackManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return err
-	}
-	imagePipeline := manifest.NewRawImage(m, buildPipeline, treePipeline)
-	p := manifest.NewQCOW2(m, buildPipeline, imagePipeline)
-	p.Filename = t.filename
-
-	return nil
-}
-
-func ec2CommonManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand,
-	diskfile string) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return nil
-	}
-	p := manifest.NewRawImage(m, buildPipeline, treePipeline)
-	p.Filename = 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,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-	return ec2CommonManifest(m, workload, t, customizations, options, repos, packageSets, rng, t.Filename())
-}
-
-func iotInstallerManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-
-	d := t.arch.distro
-	ksUsers := len(customizations.GetUsers())+len(customizations.GetGroups()) > 0
-
-	anacondaTreePipeline := anacondaTreePipeline(m,
-		buildPipeline,
-		t.platform,
-		repos,
-		packageSets[installerPkgsKey],
-		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,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet) (*manifest.Build,
-	*manifest.OSTreeCommit,
-	error) {
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[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,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline, commitPipeline, err := iotCorePipelines(m, workload, t, customizations, options, repos, packageSets)
-	if err != nil {
-		return err
-	}
-	p := manifest.NewTar(m, buildPipeline, &commitPipeline.Base, "commit-archive")
-	p.Filename = t.Filename()
-
-	return nil
-}
-
-func iotContainerManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-	buildPipeline, commitPipeline, err := iotCorePipelines(m, workload, t, customizations, options, repos, packageSets)
-	if err != nil {
-		return err
-	}
-
-	nginxConfigPath := "/etc/nginx.conf"
-	httpPort := "8080"
-	containerTreePipeline := containerTreePipeline(m,
-		buildPipeline,
-		commitPipeline,
-		t.platform,
-		repos,
-		packageSets[containerPkgsKey],
-		options,
-		customizations,
-		nginxConfigPath,
-		httpPort)
-	containerPipeline(m, buildPipeline, containerTreePipeline, t, nginxConfigPath, httpPort)
-
-	return nil
-}
-
-func containerManifest(m *manifest.Manifest,
-	workload workload.Workload,
-	t *imageType,
-	customizations *blueprint.Customizations,
-	options distro.ImageOptions,
-	repos []rpmmd.RepoConfig,
-	packageSets map[string]rpmmd.PackageSet,
-	rng *rand.Rand) error {
-
-	buildPipeline := manifest.NewBuild(m, t.arch.distro.runner, repos)
-	treePipeline, err := osPipeline(m, buildPipeline, workload, t, repos, packageSets[osPkgsKey], customizations, options, rng)
-	if err != nil {
-		return err
-	}
-	ociPipeline := manifest.NewOCIContainer(m, buildPipeline, treePipeline)
-	ociPipeline.Filename = t.Filename()
-
-	return nil
-}
-
-func osPipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	workload workload.Workload,
-	t *imageType,
-	repos []rpmmd.RepoConfig,
-	osPackageSet rpmmd.PackageSet,
-	c *blueprint.Customizations,
-	options distro.ImageOptions,
-	rng *rand.Rand) (*manifest.OS, error) {
-
-	imageConfig := t.getDefaultImageConfig()
-
-	pl := manifest.NewOS(m, buildPipeline, t.platform, repos)
-	pl.Environment = t.environment
-	pl.Workload = workload
-
-	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.bootable || t.rpmOstree {
-		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.OSTreeParent
-		if options.OSTree.Parent != "" && options.OSTree.URL != "" {
-			parent = &manifest.OSTreeParent{
-				Checksum: options.OSTree.Parent,
-				URL:      options.OSTree.URL,
-			}
-		}
-		pl.OSTree = &manifest.OSTree{
-			Parent: parent,
-		}
-	}
-
-	pl.ExtraBasePackages = osPackageSet.Include
-	pl.ExcludeBasePackages = osPackageSet.Exclude
-	pl.ExtraBaseRepos = osPackageSet.Repositories
-
-	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()
-	}
-
-	pl.EnabledServices = imageConfig.EnabledServices
-	pl.DisabledServices = imageConfig.DisabledServices
-	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
-
-	return pl, nil
-}
-
-func ostreeCommitPipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	treePipeline *manifest.OS,
-	options distro.ImageOptions,
-	osVersion string) *manifest.OSTreeCommit {
-	p := manifest.NewOSTreeCommit(m, buildPipeline, treePipeline, options.OSTree.Ref)
-	p.OSVersion = osVersion
-	return p
-}
-
-func containerTreePipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	commitPipeline *manifest.OSTreeCommit,
-	platform platform.Platform,
-	repos []rpmmd.RepoConfig,
-	containerPackageSet rpmmd.PackageSet,
-	options distro.ImageOptions,
-	c *blueprint.Customizations,
-	nginxConfigPath,
-	listenPort string) *manifest.OSTreeCommitServer {
-	p := manifest.NewOSTreeCommitServer(m, buildPipeline, platform, repos, commitPipeline, nginxConfigPath, listenPort)
-	p.ExtraPackages = containerPackageSet.Include
-	p.ExtraRepos = containerPackageSet.Repositories
-	language, _ := c.GetPrimaryLocale()
-	if language != nil {
-		p.Language = *language
-	}
-	return p
-}
-
-func containerPipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	treePipeline manifest.Tree,
-	t *imageType,
-	nginxConfigPath,
-	listenPort string) *manifest.OCIContainer {
-	p := manifest.NewOCIContainer(m, buildPipeline, treePipeline)
-	p.Filename = t.Filename()
-	p.Cmd = []string{"nginx", "-c", nginxConfigPath}
-	p.ExposedPorts = []string{listenPort}
-	return p
-}
-
-func anacondaTreePipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	pf platform.Platform,
-	repos []rpmmd.RepoConfig,
-	installerPackageSet rpmmd.PackageSet,
-	product, osVersion, variant string,
-	users bool) *manifest.Anaconda {
-	p := manifest.NewAnaconda(m, buildPipeline, pf, repos, "kernel", product, osVersion)
-	p.ExtraPackages = installerPackageSet.Include
-	p.ExtraRepos = installerPackageSet.Repositories
-
-	p.Users = users
-	p.Variant = variant
-	p.Biosdevname = (pf.GetArch() == platform.ARCH_X86_64)
-	return p
-}
-
-func bootISOTreePipeline(m *manifest.Manifest,
-	buildPipeline *manifest.Build,
-	anacondaPipeline *manifest.Anaconda,
-	options distro.ImageOptions,
-	vendor,
-	isoLabelTempl string,
-	users []blueprint.UserCustomization,
-	groups []blueprint.GroupCustomization) *manifest.ISOTree {
-	p := manifest.NewISOTree(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.Build,
-	treePipeline *manifest.ISOTree,
-	filename string,
-	isolinux bool) *manifest.ISO {
-	p := manifest.NewISO(m, buildPipeline, treePipeline)
-	p.ISOLinux = isolinux
-	p.Filename = filename
-	return p
-}
diff --git a/internal/distro/fedora/package_sets.go b/internal/distro/fedora/package_sets.go
index a4fa9bb96..c2883cf66 100644
--- a/internal/distro/fedora/package_sets.go
+++ b/internal/distro/fedora/package_sets.go
@@ -266,15 +266,6 @@ func installerPackageSet(t *imageType) rpmmd.PackageSet {
 		},
 	}
 
-	switch t.Arch().Name() {
-	case distro.X86_64ArchName:
-		ps = ps.Append(rpmmd.PackageSet{
-			Include: []string{
-				"biosdevname",
-			},
-		})
-	}
-
 	return ps
 }
 
diff --git a/internal/image/container.go b/internal/image/container.go
new file mode 100644
index 000000000..af53a3b7e
--- /dev/null
+++ b/internal/image/container.go
@@ -0,0 +1,47 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/environment"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/platform"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+	"github.com/osbuild/osbuild-composer/internal/workload"
+)
+
+type BaseContainer struct {
+	Base
+	Platform         platform.Platform
+	OSCustomizations manifest.OSCustomizations
+	Environment      environment.Environment
+	Workload         workload.Workload
+	Filename         string
+}
+
+func NewBaseContainer() *BaseContainer {
+	return &BaseContainer{
+		Base: NewBase("base-container"),
+	}
+}
+
+func (img *BaseContainer) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
+	buildPipeline := manifest.NewBuild(m, runner, repos)
+	buildPipeline.Checkpoint()
+
+	osPipeline := manifest.NewOS(m, buildPipeline, img.Platform, repos)
+	osPipeline.OSCustomizations = img.OSCustomizations
+	osPipeline.Environment = img.Environment
+	osPipeline.Workload = img.Workload
+
+	ociPipeline := manifest.NewOCIContainer(m, buildPipeline, osPipeline)
+	ociPipeline.Filename = img.Filename
+	artifact := ociPipeline.Export()
+
+	return artifact, nil
+}
diff --git a/internal/image/image.go b/internal/image/image.go
new file mode 100644
index 000000000..ddd16c04c
--- /dev/null
+++ b/internal/image/image.go
@@ -0,0 +1,29 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+)
+
+type ImageKind interface {
+	Name() string
+	InstantiateManifest(m *manifest.Manifest, repos []rpmmd.RepoConfig, runner runner.Runner, rng *rand.Rand) (*artifact.Artifact, error)
+}
+
+type Base struct {
+	name string
+}
+
+func (img Base) Name() string {
+	return img.name
+}
+
+func NewBase(name string) Base {
+	return Base{
+		name: name,
+	}
+}
diff --git a/internal/image/live.go b/internal/image/live.go
new file mode 100644
index 000000000..ccc20613f
--- /dev/null
+++ b/internal/image/live.go
@@ -0,0 +1,70 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/disk"
+	"github.com/osbuild/osbuild-composer/internal/environment"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/platform"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+	"github.com/osbuild/osbuild-composer/internal/workload"
+)
+
+type LiveImage struct {
+	Base
+	Platform         platform.Platform
+	PartitionTable   *disk.PartitionTable
+	OSCustomizations manifest.OSCustomizations
+	Environment      environment.Environment
+	Workload         workload.Workload
+	Filename         string
+}
+
+func NewLiveImage() *LiveImage {
+	return &LiveImage{
+		Base: NewBase("live-image"),
+	}
+}
+
+func (img *LiveImage) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
+	buildPipeline := manifest.NewBuild(m, runner, repos)
+	buildPipeline.Checkpoint()
+
+	osPipeline := manifest.NewOS(m, buildPipeline, img.Platform, repos)
+	osPipeline.PartitionTable = img.PartitionTable
+	osPipeline.OSCustomizations = img.OSCustomizations
+	osPipeline.Environment = img.Environment
+	osPipeline.Workload = img.Workload
+
+	imagePipeline := manifest.NewRawImage(m, buildPipeline, osPipeline)
+
+	var artifact *artifact.Artifact
+	switch img.Platform.GetImageFormat() {
+	case platform.FORMAT_RAW:
+		imagePipeline.Filename = img.Filename
+		artifact = imagePipeline.Export()
+	case platform.FORMAT_QCOW2:
+		qcow2Pipeline := manifest.NewQCOW2(m, buildPipeline, imagePipeline)
+		qcow2Pipeline.Filename = img.Filename
+		qcow2Pipeline.Compat = img.Platform.GetQCOW2Compat()
+		artifact = qcow2Pipeline.Export()
+	case platform.FORMAT_VHD:
+		vpcPipeline := manifest.NewVPC(m, buildPipeline, imagePipeline)
+		vpcPipeline.Filename = img.Filename
+		artifact = vpcPipeline.Export()
+	case platform.FORMAT_VMDK:
+		vmdkPipeline := manifest.NewVMDK(m, buildPipeline, imagePipeline)
+		vmdkPipeline.Filename = img.Filename
+		artifact = vmdkPipeline.Export()
+	default:
+		panic("invalid image format for image kind")
+	}
+
+	return artifact, nil
+}
diff --git a/internal/image/ostree_archive.go b/internal/image/ostree_archive.go
new file mode 100644
index 000000000..ff6e6c0a4
--- /dev/null
+++ b/internal/image/ostree_archive.go
@@ -0,0 +1,54 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/environment"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/platform"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+	"github.com/osbuild/osbuild-composer/internal/workload"
+)
+
+type OSTreeArchive struct {
+	Base
+	Platform         platform.Platform
+	OSCustomizations manifest.OSCustomizations
+	Environment      environment.Environment
+	Workload         workload.Workload
+	OSTreeParent     manifest.OSTree
+	OSTreeRef        string
+	OSVersion        string
+	Filename         string
+}
+
+func NewOSTreeArchive() *OSTreeArchive {
+	return &OSTreeArchive{
+		Base: NewBase("ostree-archive"),
+	}
+}
+
+func (img *OSTreeArchive) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
+	buildPipeline := manifest.NewBuild(m, runner, repos)
+	buildPipeline.Checkpoint()
+
+	osPipeline := manifest.NewOS(m, buildPipeline, img.Platform, repos)
+	osPipeline.OSCustomizations = img.OSCustomizations
+	osPipeline.Environment = img.Environment
+	osPipeline.Workload = img.Workload
+	osPipeline.OSTree = &img.OSTreeParent
+
+	ostreeCommitPipeline := manifest.NewOSTreeCommit(m, buildPipeline, osPipeline, img.OSTreeRef)
+	ostreeCommitPipeline.OSVersion = img.OSVersion
+
+	tarPipeline := manifest.NewTar(m, buildPipeline, &ostreeCommitPipeline.Base, "commit-archive")
+	tarPipeline.Filename = img.Filename
+	artifact := tarPipeline.Export()
+
+	return artifact, nil
+}
diff --git a/internal/image/ostree_container.go b/internal/image/ostree_container.go
new file mode 100644
index 000000000..fd2419416
--- /dev/null
+++ b/internal/image/ostree_container.go
@@ -0,0 +1,70 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/environment"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/platform"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+	"github.com/osbuild/osbuild-composer/internal/workload"
+)
+
+type OSTreeContainer struct {
+	Base
+	Platform               platform.Platform
+	OSCustomizations       manifest.OSCustomizations
+	Environment            environment.Environment
+	Workload               workload.Workload
+	OSTreeParent           manifest.OSTree
+	OSTreeRef              string // TODO: merge into the above
+	OSVersion              string
+	ExtraContainerPackages rpmmd.PackageSet
+	ContainerLanguage      string
+	Filename               string
+}
+
+func NewOSTreeContainer() *OSTreeContainer {
+	return &OSTreeContainer{
+		Base: NewBase("ostree-container"),
+	}
+}
+
+func (img *OSTreeContainer) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
+	buildPipeline := manifest.NewBuild(m, runner, repos)
+	buildPipeline.Checkpoint()
+
+	osPipeline := manifest.NewOS(m, buildPipeline, img.Platform, repos)
+	osPipeline.OSCustomizations = img.OSCustomizations
+	osPipeline.Environment = img.Environment
+	osPipeline.Workload = img.Workload
+	osPipeline.OSTree = &img.OSTreeParent
+
+	commitPipeline := manifest.NewOSTreeCommit(m, buildPipeline, osPipeline, img.OSTreeRef)
+	commitPipeline.OSVersion = img.OSVersion
+
+	nginxConfigPath := "/etc/nginx.conf"
+	listenPort := "8080"
+
+	serverPipeline := manifest.NewOSTreeCommitServer(m,
+		buildPipeline,
+		img.Platform,
+		repos,
+		commitPipeline,
+		nginxConfigPath,
+		listenPort)
+	serverPipeline.Language = img.ContainerLanguage
+
+	containerPipeline := manifest.NewOCIContainer(m, buildPipeline, serverPipeline)
+	containerPipeline.Cmd = []string{"nginx", "-c", nginxConfigPath}
+	containerPipeline.ExposedPorts = []string{listenPort}
+	containerPipeline.Filename = img.Filename
+	artifact := containerPipeline.Export()
+
+	return artifact, nil
+}
diff --git a/internal/image/ostree_installer.go b/internal/image/ostree_installer.go
new file mode 100644
index 000000000..8cdbd9a3f
--- /dev/null
+++ b/internal/image/ostree_installer.go
@@ -0,0 +1,79 @@
+package image
+
+import (
+	"math/rand"
+
+	"github.com/osbuild/osbuild-composer/internal/artifact"
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/manifest"
+	"github.com/osbuild/osbuild-composer/internal/platform"
+	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/runner"
+)
+
+type OSTreeInstaller struct {
+	Base
+	Platform          platform.Platform
+	ExtraBasePackages rpmmd.PackageSet
+	Users             []blueprint.UserCustomization
+	Groups            []blueprint.GroupCustomization
+
+	ISOLabelTempl string
+	Product       string
+	Variant       string
+	OSName        string
+	OSVersion     string
+	Release       string
+
+	OSTreeURL    string
+	OSTreeRef    string
+	OSTreeCommit string
+
+	Filename string
+}
+
+func NewOSTreeInstaller() *OSTreeInstaller {
+	return &OSTreeInstaller{
+		Base: NewBase("ostree-installer"),
+	}
+}
+
+func (img *OSTreeInstaller) InstantiateManifest(m *manifest.Manifest,
+	repos []rpmmd.RepoConfig,
+	runner runner.Runner,
+	rng *rand.Rand) (*artifact.Artifact, error) {
+	buildPipeline := manifest.NewBuild(m, runner, repos)
+	buildPipeline.Checkpoint()
+
+	anacondaPipeline := manifest.NewAnaconda(m,
+		buildPipeline,
+		img.Platform,
+		repos, "kernel",
+		img.Product,
+		img.OSVersion)
+	anacondaPipeline.ExtraPackages = img.ExtraBasePackages.Include
+	anacondaPipeline.ExtraRepos = img.ExtraBasePackages.Repositories
+	anacondaPipeline.Users = len(img.Users)+len(img.Groups) > 0
+	anacondaPipeline.Variant = img.Variant
+	anacondaPipeline.Biosdevname = (img.Platform.GetArch() == platform.ARCH_X86_64)
+	anacondaPipeline.Checkpoint()
+
+	isoTreePipeline := manifest.NewISOTree(m,
+		buildPipeline,
+		anacondaPipeline,
+		img.OSTreeCommit,
+		img.OSTreeURL,
+		img.OSTreeRef,
+		img.ISOLabelTempl)
+	isoTreePipeline.Release = img.Release
+	isoTreePipeline.OSName = img.OSName
+	isoTreePipeline.UEFIVendor = img.Platform.GetUEFIVendor()
+	isoTreePipeline.Users = img.Users
+	isoTreePipeline.Groups = img.Groups
+
+	isoPipeline := manifest.NewISO(m, buildPipeline, isoTreePipeline)
+	isoPipeline.Filename = img.Filename
+	artifact := isoPipeline.Export()
+
+	return artifact, nil
+}
diff --git a/internal/manifest/anaconda.go b/internal/manifest/anaconda.go
index a89dd7c43..f4ddcacec 100644
--- a/internal/manifest/anaconda.go
+++ b/internal/manifest/anaconda.go
@@ -110,6 +110,9 @@ func (p *Anaconda) getBuildPackages() []string {
 
 func (p *Anaconda) getPackageSetChain() []rpmmd.PackageSet {
 	packages := p.anacondaBootPackageSet()
+	if p.Biosdevname {
+		packages = append(packages, "biosdevname")
+	}
 	return []rpmmd.PackageSet{
 		{
 			Include:      append(packages, p.ExtraPackages...),
diff --git a/internal/manifest/iso.go b/internal/manifest/iso.go
index 1c53eaf2f..8fd638670 100644
--- a/internal/manifest/iso.go
+++ b/internal/manifest/iso.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -66,3 +67,9 @@ func xorrisofsStageOptions(filename, isolabel string, isolinux bool) *osbuild.Xo
 
 	return options
 }
+
+func (p *ISO) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-iso9660-image"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go
index bbff67aeb..a8e8143d0 100644
--- a/internal/manifest/manifest.go
+++ b/internal/manifest/manifest.go
@@ -88,3 +88,23 @@ func (m Manifest) Serialize(packageSets map[string][]rpmmd.PackageSpec) (distro.
 		},
 	)
 }
+
+func (m Manifest) GetCheckpoints() []string {
+	checkpoints := []string{}
+	for _, p := range m.pipelines {
+		if p.getCheckpoint() {
+			checkpoints = append(checkpoints, p.Name())
+		}
+	}
+	return checkpoints
+}
+
+func (m Manifest) GetExports() []string {
+	exports := []string{}
+	for _, p := range m.pipelines {
+		if p.getExport() {
+			exports = append(exports, p.Name())
+		}
+	}
+	return exports
+}
diff --git a/internal/manifest/oci_container.go b/internal/manifest/oci_container.go
index ebaadd72b..9807f9611 100644
--- a/internal/manifest/oci_container.go
+++ b/internal/manifest/oci_container.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -55,3 +56,9 @@ func (p *OCIContainer) serialize() osbuild.Pipeline {
 func (p *OCIContainer) getBuildPackages() []string {
 	return []string{"tar"}
 }
+
+func (p *OCIContainer) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-tar"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/manifest/os.go b/internal/manifest/os.go
index 5f9b92af8..8f294c872 100644
--- a/internal/manifest/os.go
+++ b/internal/manifest/os.go
@@ -24,10 +24,13 @@ type OSTreeParent struct {
 	URL      string
 }
 
-// OS represents the filesystem tree of the target image. This roughly
-// correpsonds to the root filesystem once an instance of the image is running.
-type OS struct {
-	Base
+// OSCustomizations encapsulates all configuration applied to the base
+// operating independently of where and how it is integrated and what
+// workload it is running.
+// TODO: move out kernel/bootloader/cloud-init/... to other
+//       abstractions, this should ideally only contain things that
+//       can always be applied.
+type OSCustomizations struct {
 	// Packages to install in addition to the ones required by the
 	// pipeline.
 	ExtraBasePackages []string
@@ -38,14 +41,6 @@ type OS struct {
 	ExcludeBasePackages []string
 	// Additional repos to install the base packages from.
 	ExtraBaseRepos []rpmmd.RepoConfig
-	// Environment the system will run in
-	Environment environment.Environment
-	// Workload to install on top of the base system
-	Workload workload.Workload
-	// OSTree configuration, if nil the tree cannot be in an OSTree commit
-	OSTree *OSTree
-	// 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
@@ -92,6 +87,22 @@ type OS struct {
 	SshdConfig    *osbuild.SshdConfigStageOptions
 	AuthConfig    *osbuild.AuthconfigStageOptions
 	PwQuality     *osbuild.PwqualityConfStageOptions
+}
+
+// OS represents the filesystem tree of the target image. This roughly
+// corresponds to the root filesystem once an instance of the image is running.
+type OS struct {
+	Base
+	// Customizations to apply to the base OS
+	OSCustomizations
+	// Environment the system will run in
+	Environment environment.Environment
+	// Workload to install on top of the base system
+	Workload workload.Workload
+	// OSTree configuration, if nil the tree cannot be in an OSTree commit
+	OSTree *OSTree
+	// Partition table, if nil the tree cannot be put on a partioned disk
+	PartitionTable *disk.PartitionTable
 
 	repos        []rpmmd.RepoConfig
 	packageSpecs []rpmmd.PackageSpec
@@ -113,10 +124,6 @@ func NewOS(m *Manifest,
 		Base:     NewBase(m, "os", buildPipeline),
 		repos:    repos,
 		platform: platform,
-		Language: "C.UTF-8",
-		Hostname: "localhost.localdomain",
-		Timezone: "UTC",
-		SElinux:  "targeted",
 	}
 	buildPipeline.addDependent(p)
 	m.addPipeline(p)
@@ -156,10 +163,13 @@ func (p *OS) getPackageSetChain() []rpmmd.PackageSet {
 	}
 
 	if p.Workload != nil {
-		chain = append(chain, rpmmd.PackageSet{
-			Include:      p.Workload.GetPackages(),
-			Repositories: append(p.repos, p.Workload.GetRepos()...),
-		})
+		workloadPackages := p.Workload.GetPackages()
+		if len(workloadPackages) > 0 {
+			chain = append(chain, rpmmd.PackageSet{
+				Include:      workloadPackages,
+				Repositories: append(p.repos, p.Workload.GetRepos()...),
+			})
+		}
 	}
 
 	return chain
diff --git a/internal/manifest/pipeline.go b/internal/manifest/pipeline.go
index 0b330e4a9..eedb96dd4 100644
--- a/internal/manifest/pipeline.go
+++ b/internal/manifest/pipeline.go
@@ -7,6 +7,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 	"github.com/osbuild/osbuild-composer/internal/platform"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
@@ -14,6 +15,10 @@ import (
 
 type Pipeline interface {
 	Name() string
+	Checkpoint()
+	Export() *artifact.Artifact
+	getCheckpoint() bool
+	getExport() bool
 	getBuildPackages() []string
 	getPackageSetChain() []rpmmd.PackageSet
 	serializeStart([]rpmmd.PackageSpec)
@@ -27,9 +32,11 @@ type Pipeline interface {
 // A Base represents the core functionality shared between each of the pipeline
 // implementations, and the Base struct must be embedded in each of them.
 type Base struct {
-	manifest *Manifest
-	name     string
-	build    *Build
+	manifest   *Manifest
+	name       string
+	build      *Build
+	checkpoint bool
+	export     bool
 }
 
 // Name returns the name of the pipeline. The name must be unique for a given manifest.
@@ -39,6 +46,22 @@ func (p Base) Name() string {
 	return p.name
 }
 
+func (p *Base) Checkpoint() {
+	p.checkpoint = true
+}
+
+func (p Base) getCheckpoint() bool {
+	return p.checkpoint
+}
+
+func (p *Base) Export() *artifact.Artifact {
+	panic("can't export pipeline")
+}
+
+func (p Base) getExport() bool {
+	return p.export
+}
+
 func (p Base) GetManifest() *Manifest {
 	return p.manifest
 }
diff --git a/internal/manifest/qcow2.go b/internal/manifest/qcow2.go
index 404be2450..7471c4e12 100644
--- a/internal/manifest/qcow2.go
+++ b/internal/manifest/qcow2.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -50,3 +51,9 @@ func (p *QCOW2) serialize() osbuild.Pipeline {
 func (p *QCOW2) getBuildPackages() []string {
 	return []string{"qemu-img"}
 }
+
+func (p *QCOW2) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-qemu-disk"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/manifest/raw.go b/internal/manifest/raw.go
index a2a3dce7a..f78f7386f 100644
--- a/internal/manifest/raw.go
+++ b/internal/manifest/raw.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 	"github.com/osbuild/osbuild-composer/internal/platform"
 )
@@ -66,3 +67,8 @@ func (p *RawImage) serialize() osbuild.Pipeline {
 
 	return pipeline
 }
+
+func (p *RawImage) Export() *artifact.Artifact {
+	p.Base.export = true
+	return artifact.New(p.Name(), p.Filename, nil)
+}
diff --git a/internal/manifest/tar.go b/internal/manifest/tar.go
index 695086fd9..fb2d6d40e 100644
--- a/internal/manifest/tar.go
+++ b/internal/manifest/tar.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -49,3 +50,9 @@ func (p *Tar) serialize() osbuild.Pipeline {
 func (p *Tar) getBuildPackages() []string {
 	return []string{"tar"}
 }
+
+func (p *Tar) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-tar"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/manifest/vmdk.go b/internal/manifest/vmdk.go
index 2a7d06dc8..8349661b0 100644
--- a/internal/manifest/vmdk.go
+++ b/internal/manifest/vmdk.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -46,3 +47,9 @@ func (p *VMDK) serialize() osbuild.Pipeline {
 func (p *VMDK) getBuildPackages() []string {
 	return []string{"qemu-img"}
 }
+
+func (p *VMDK) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-vmdk"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/manifest/vpc.go b/internal/manifest/vpc.go
index 20404b8f0..3f1c7b63f 100644
--- a/internal/manifest/vpc.go
+++ b/internal/manifest/vpc.go
@@ -1,6 +1,7 @@
 package manifest
 
 import (
+	"github.com/osbuild/osbuild-composer/internal/artifact"
 	"github.com/osbuild/osbuild-composer/internal/osbuild"
 )
 
@@ -45,3 +46,9 @@ func (p *VPC) serialize() osbuild.Pipeline {
 func (p *VPC) getBuildPackages() []string {
 	return []string{"qemu-img"}
 }
+
+func (p *VPC) Export() *artifact.Artifact {
+	p.Base.export = true
+	mimeType := "application/x-vhd"
+	return artifact.New(p.Name(), p.Filename, &mimeType)
+}
diff --git a/internal/platform/platform.go b/internal/platform/platform.go
index 259ab9cef..3de131eba 100644
--- a/internal/platform/platform.go
+++ b/internal/platform/platform.go
@@ -1,6 +1,7 @@
 package platform
 
 type Arch uint64
+type ImageFormat uint64
 
 const (
 	ARCH_AARCH64 Arch = iota
@@ -9,6 +10,15 @@ const (
 	ARCH_X86_64
 )
 
+const (
+	FORMAT_UNSET ImageFormat = iota
+	FORMAT_RAW
+	FORMAT_ISO
+	FORMAT_QCOW2
+	FORMAT_VMDK
+	FORMAT_VHD
+)
+
 func (a Arch) String() string {
 	switch a {
 	case ARCH_AARCH64:
@@ -24,8 +34,27 @@ func (a Arch) String() string {
 	}
 }
 
+func (f ImageFormat) String() string {
+	switch f {
+	case FORMAT_RAW:
+		return "raw"
+	case FORMAT_ISO:
+		return "iso"
+	case FORMAT_QCOW2:
+		return "qcow2"
+	case FORMAT_VMDK:
+		return "vmdk"
+	case FORMAT_VHD:
+		return "vhd"
+	default:
+		panic("invalid architecture")
+	}
+}
+
 type Platform interface {
 	GetArch() Arch
+	GetImageFormat() ImageFormat
+	GetQCOW2Compat() string
 	GetBIOSPlatform() string
 	GetUEFIVendor() string
 	GetZiplSupport() bool
@@ -34,9 +63,19 @@ type Platform interface {
 }
 
 type BasePlatform struct {
+	ImageFormat      ImageFormat
+	QCOW2Compat      string
 	FirmwarePackages []string
 }
 
+func (p BasePlatform) GetImageFormat() ImageFormat {
+	return p.ImageFormat
+}
+
+func (p BasePlatform) GetQCOW2Compat() string {
+	return p.QCOW2Compat
+}
+
 func (p BasePlatform) GetBIOSPlatform() string {
 	return ""
 }
diff --git a/internal/worker/clienterrors/errors.go b/internal/worker/clienterrors/errors.go
index b8587f004..54290f714 100644
--- a/internal/worker/clienterrors/errors.go
+++ b/internal/worker/clienterrors/errors.go
@@ -29,6 +29,7 @@ const (
 	ErrorJobDependency        ClientErrorCode = 26
 	ErrorJobMissingHeartbeat  ClientErrorCode = 27
 	ErrorTargetError          ClientErrorCode = 28
+	ErrorParsingJobArgs       ClientErrorCode = 29
 )
 
 type ClientErrorCode int
diff --git a/internal/worker/json.go b/internal/worker/json.go
index bcb1a951c..817665e7b 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -75,6 +75,20 @@ func (j *OSBuildJobResult) TargetErrors() []*clienterrors.Error {
 	return targetErrors
 }
 
+// TargetResultsByName iterates over TargetResults attached to the Job result and
+// returns a slice of Target results of the provided name (type). If there were no
+// TargetResults of the desired type attached to the Job results, the returned
+// slice will be empty.
+func (j *OSBuildJobResult) TargetResultsByName(name target.TargetName) []*target.TargetResult {
+	targetResults := []*target.TargetResult{}
+	for _, targetResult := range j.TargetResults {
+		if targetResult.Name == name {
+			targetResults = append(targetResults, targetResult)
+		}
+	}
+	return targetResults
+}
+
 type KojiInitJob struct {
 	Server  string `json:"server"`
 	Name    string `json:"name"`
diff --git a/tools/koji-compose.py b/tools/koji-compose.py
index bbde97e5a..8f95d9f6c 100755
--- a/tools/koji-compose.py
+++ b/tools/koji-compose.py
@@ -2,6 +2,7 @@
 import json
 import sys
 import time
+import os
 
 import requests
 
@@ -28,12 +29,12 @@ def compose_request(distro, koji, arch):
     repositories = [composer_repository_to_koji_repository(repo) for repo in test_repositories[arch]]
     image_requests = [
         {
-            "architecture": "x86_64",
+            "architecture": arch,
             "image_type": "guest-image",
             "repositories": repositories
         },
         {
-            "architecture": "x86_64",
+            "architecture": arch,
             "image_type": "aws",
             "repositories": repositories
         }
@@ -54,42 +55,112 @@ def compose_request(distro, koji, arch):
     return req
 
 
-def main(distro, arch):
-    cr = compose_request(distro, "https://localhost:4343/kojihub", arch)
-    print(json.dumps(cr))
+def upload_options_by_cloud_target(cloud_target):
+    if cloud_target == "aws":
+        return {
+            # the snapshot name is currently not set for Koji composes
+            # "snapshot_name": "",
+            "region": os.getenv("AWS_REGION"),
+            "share_with_accounts": [os.getenv("AWS_API_TEST_SHARE_ACCOUNT")]
+        }
+    elif cloud_target == "azure":
+        return {
+            # image name is currently not set for Koji composes
+            # "image_name": "",
+            "location": os.getenv("AZURE_LOCATION"),
+            "resource_group": os.getenv("AZURE_RESOURCE_GROUP"),
+            "subscription_id": os.getenv("AZURE_SUBSCRIPTION_ID"),
+            "tenant_id": os.getenv("AZURE_TENANT_ID")
+        }
+    elif cloud_target == "gcp":
+        return {
+            # image name is currently not set for Koji composes
+            # "image_name": "",
+            "bucket": os.getenv("GCP_BUCKET"),
+            "region": os.getenv("GCP_REGION"),
+            "share_with_accounts": [os.getenv("GCP_API_TEST_SHARE_ACCOUNT")]
+        }
+    else:
+        raise RuntimeError(f"unsupported target cloud: {cloud_target}")
+
+
+def compose_request_cloud_upload(distro, koji, arch, cloud_target, image_type):
+    with open(f"/usr/share/tests/osbuild-composer/repositories/{distro}.json") as f:
+        test_repositories = json.load(f)
+
+    repositories = [composer_repository_to_koji_repository(repo) for repo in test_repositories[arch]]
+    image_requests = [
+        {
+            "architecture": arch,
+            "image_type": image_type,
+            "repositories": repositories,
+            "upload_options": upload_options_by_cloud_target(cloud_target)
+        },
+    ]
+
+    req = {
+        "distribution": distro,
+        "koji": {
+            "server": koji,
+            "task_id": 1,
+            "name": "name",
+            "version": "version",
+            "release": "release",
+        },
+        "image_requests": image_requests
+    }
+
+    return req
+
+
+def main():
+    koji_url = "https://localhost:4343/kojihub"
+    if len(sys.argv) == 3:
+        distro, arch = sys.argv[1], sys.argv[2]
+        print("Using simple Koji compose with 2 image requests", file=sys.stderr)
+        cr = compose_request(distro, koji_url, arch)
+    elif len(sys.argv) == 5:
+        distro, arch, cloud_target, image_type = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
+        print("Using Koji compose with 1 image requests and upload to cloud", file=sys.stderr)
+        cr = compose_request_cloud_upload(distro, koji_url, arch, cloud_target, image_type)
+    else:
+        print(f"usage: {sys.argv[0]} DISTRO ARCH [CLOUD_TARGET IMAGE_TYPE]", file=sys.stderr)
+        sys.exit(1)
+    print(json.dumps(cr), file=sys.stderr)
 
     r = requests.post("https://localhost/api/image-builder-composer/v2/compose", json=cr,
                       cert=("/etc/osbuild-composer/worker-crt.pem", "/etc/osbuild-composer/worker-key.pem"),
                       verify="/etc/osbuild-composer/ca-crt.pem")
     if r.status_code != 201:
-        print("Failed to create compose")
-        print(r.text)
+        print("Failed to create compose", file=sys.stderr)
+        print(r.text, file=sys.stderr)
         sys.exit(1)
 
-    print(r.text)
+    print(r.text, file=sys.stderr)
     compose_id = r.json()["id"]
+    print(compose_id)
 
     while True:
         r = requests.get(f"https://localhost/api/image-builder-composer/v2/composes/{compose_id}",
                          cert=("/etc/osbuild-composer/worker-crt.pem", "/etc/osbuild-composer/worker-key.pem"),
                          verify="/etc/osbuild-composer/ca-crt.pem")
         if r.status_code != 200:
-            print("Failed to get compose status")
-            print(r.text)
+            print("Failed to get compose status", file=sys.stderr)
+            print(r.text, file=sys.stderr)
             sys.exit(1)
         status = r.json()["status"]
-        print(status)
+        print(status, file=sys.stderr)
         if status == "success":
-            print("Compose worked!")
-            print(r.text)
+            print("Compose worked!", file=sys.stderr)
+            print(r.text, file=sys.stderr)
             break
         elif status == "failure":
-            print("compose failed!")
-            print(r.text)
+            print("compose failed!", file=sys.stderr)
+            print(r.text, file=sys.stderr)
             sys.exit(1)
         elif status != "pending" and status != "running":
-            print(f"unexpected status: {status}")
-            print(r.text)
+            print(f"unexpected status: {status}", file=sys.stderr)
+            print(r.text, file=sys.stderr)
             sys.exit(1)
 
         time.sleep(10)
@@ -104,7 +175,4 @@ def main(distro, arch):
 
 
 if __name__ == "__main__":
-    if len(sys.argv) != 3:
-        print(f"usage: {sys.argv[0]} DISTRO ARCH", file=sys.stderr)
-        sys.exit(1)
-    main(sys.argv[1], sys.argv[2])
+    main()


Test patch:
diff --git a/internal/worker/json_test.go b/internal/worker/json_test.go
index 6f629e27f..f3565f44f 100644
--- a/internal/worker/json_test.go
+++ b/internal/worker/json_test.go
@@ -87,6 +87,108 @@ func TestOSBuildJobResultTargetErrors(t *testing.T) {
 	}
 }
 
+func TestOSBuildJobResultTargetResultsByName(t *testing.T) {
+	testCases := []struct {
+		jobResult     OSBuildJobResult
+		targetName    target.TargetName
+		targetResults []*target.TargetResult
+	}{
+		// one target results of a given name
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name:        target.TargetNameAWS,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameAWSS3,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3"),
+					},
+				},
+			},
+			targetName: target.TargetNameAWS,
+			targetResults: []*target.TargetResult{
+				{
+					Name:        target.TargetNameAWS,
+					TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+				},
+			},
+		},
+		// multiple target results of a given name
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name:        target.TargetNameAWS,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameAWSS3,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3"),
+					},
+				},
+			},
+			targetName: target.TargetNameVMWare,
+			targetResults: []*target.TargetResult{
+				{
+					Name:        target.TargetNameVMWare,
+					TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+				},
+				{
+					Name:        target.TargetNameVMWare,
+					TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+				},
+			},
+		},
+		// no target result of a given name
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name:        target.TargetNameAWS,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameAWSS3,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3"),
+					},
+				},
+			},
+			targetName:    target.TargetNameKoji,
+			targetResults: []*target.TargetResult{},
+		},
+	}
+
+	for _, testCase := range testCases {
+		assert.EqualValues(t, testCase.targetResults, testCase.jobResult.TargetResultsByName(testCase.targetName))
+	}
+}
+
 func TestOSBuildJobExports(t *testing.T) {
 	testCases := []struct {
 		job             *OSBuildJob
diff --git a/test/cases/api/azure.sh b/test/cases/api/azure.sh
index 76be691b8..263cc253d 100644
--- a/test/cases/api/azure.sh
+++ b/test/cases/api/azure.sh
@@ -109,11 +109,16 @@ function checkUploadStatusOptions() {
   test "$IMAGE_NAME" = "$AZURE_IMAGE_NAME"
 }
 
-# Verify image in Azure
-function verify() {
+# Log into Azure
+function cloud_login() {
   set +x
   $AZURE_CMD login --service-principal --username "${V2_AZURE_CLIENT_ID}" --password "${V2_AZURE_CLIENT_SECRET}" --tenant "${AZURE_TENANT_ID}"
   set -x
+}
+
+# Verify image in Azure
+function verify() {
+  cloud_login
 
   # verify that the image exists and tag it
   $AZURE_CMD image show --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}"
diff --git a/test/cases/api/common/aws.sh b/test/cases/api/common/aws.sh
index 14c315e54..7037056b3 100644
--- a/test/cases/api/common/aws.sh
+++ b/test/cases/api/common/aws.sh
@@ -31,3 +31,10 @@ function installClient() {
   fi
   $GOVC_CMD version
 }
+
+# Log into AWS
+# AWS does not need explicit login, but define this function for the sake of
+# consistency to allow calling scripts to not care about cloud differences
+function cloud_login() {
+  true
+}
diff --git a/test/cases/api/gcp.sh b/test/cases/api/gcp.sh
index 267b8159c..d88877c8a 100644
--- a/test/cases/api/gcp.sh
+++ b/test/cases/api/gcp.sh
@@ -101,14 +101,18 @@ function checkUploadStatusOptions() {
   test "$PROJECT_ID" = "$GCP_PROJECT"
 }
 
-
-# Verify image in Compute Engine on GCP
-function verify() {
+# Log into GCP
+function cloud_login() {
   # 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"
+}
+
+# Verify image in Compute Engine on GCP
+function verify() {
+  cloud_login
 
   # Add "gitlab-ci-test" label to the image
   $GCP_CMD compute images add-labels "$GCP_IMAGE_NAME" --labels=gitlab-ci-test=true
diff --git a/test/cases/koji.sh b/test/cases/koji.sh
index 887fe73c4..1c3fa1049 100755
--- a/test/cases/koji.sh
+++ b/test/cases/koji.sh
@@ -16,6 +16,89 @@ if [ "${NIGHTLY:=false}" == "true" ]; then
     exit 0
 fi
 
+#
+# Cloud provider / target names
+#
+
+CLOUD_PROVIDER_AWS="aws"
+CLOUD_PROVIDER_GCP="gcp"
+CLOUD_PROVIDER_AZURE="azure"
+
+#
+# Test types
+#
+# Tests Koji compose via cloudapi
+TEST_TYPE_CLOUDAPI="cloudapi"
+# Tests Koji compose via cloudapi with upload to a cloud target
+TEST_TYPE_CLOUD_UPLOAD="cloud-upload"
+
+# test Koji compose via cloudapi without upload to cloud by default
+TEST_TYPE="${1:-$TEST_TYPE_CLOUDAPI}"
+
+#
+# Cloud upload - check environment and prepare it
+#
+if [[ "$TEST_TYPE" == "$TEST_TYPE_CLOUD_UPLOAD" ]]; then
+    if [[ $# -ne 3 ]]; then
+        echo "Usage: $0 cloud-upload TARGET IMAGE_TYPE"
+        exit 1
+    fi
+
+    CLOUD_PROVIDER="${2}"
+    IMAGE_TYPE="${3}"
+
+    greenprint "Using Cloud Provider / Target ${CLOUD_PROVIDER} for Image Type ${IMAGE_TYPE}"
+
+    # Load a correct test runner.
+    # Each one must define following methods:
+    # - checkEnv
+    # - cleanup
+    # - installClient
+    case $CLOUD_PROVIDER in
+        "$CLOUD_PROVIDER_AWS")
+            source /usr/libexec/tests/osbuild-composer/api/aws.sh
+            ;;
+        "$CLOUD_PROVIDER_GCP")
+            source /usr/libexec/tests/osbuild-composer/api/gcp.sh
+            ;;
+        "$CLOUD_PROVIDER_AZURE")
+            source /usr/libexec/tests/osbuild-composer/api/azure.sh
+            ;;
+        *)
+            echo "Unknown cloud provider: ${CLOUD_PROVIDER}"
+            exit 1
+    esac
+
+    # Verify that this script is running in the right environment.
+    checkEnv
+
+    # Container image used for cloud provider CLI tools
+    export CONTAINER_IMAGE_CLOUD_TOOLS="quay.io/osbuild/cloud-tools:latest"
+
+    if which podman 2>/dev/null >&2; then
+        export CONTAINER_RUNTIME=podman
+    elif which docker 2>/dev/null >&2; then
+        export CONTAINER_RUNTIME=docker
+    else
+        echo No container runtime found, install podman or docker.
+        exit 1
+    fi
+
+    WORKDIR=$(mktemp -d)
+    export WORKDIR
+    function cleanups() {
+        greenprint "Script execution stopped or finished - Cleaning up"
+        set +eu
+        cleanup
+        sudo rm -rf "$WORKDIR"
+        set -eu
+    }
+    trap cleanups EXIT
+
+    # install appropriate cloud environment client tool
+    installClient
+fi
+
 # Provision the software under test.
 /usr/libexec/osbuild-composer-test/provision.sh
 
@@ -51,13 +134,73 @@ koji --server=http://localhost:8080/kojihub --user kojiadmin --password kojipass
 
 # Always build the latest RHEL - that suits the koji API usecase the most.
 if [[ "$DISTRO_CODE" == rhel-8* ]]; then
-  DISTRO_CODE=rhel-87
+    DISTRO_CODE=rhel-87
 elif [[ "$DISTRO_CODE" == rhel-9* ]]; then
-  DISTRO_CODE=rhel-91
+    DISTRO_CODE=rhel-91
 fi
 
-greenprint "Pushing compose to Koji (/api/image-builder-comoser/v2/"
-sudo /usr/libexec/osbuild-composer-test/koji-compose.py "$DISTRO_CODE" "${ARCH}"
+case ${TEST_TYPE} in
+    "$TEST_TYPE_CLOUDAPI")
+        greenprint "Pushing compose to Koji (/api/image-builder-comoser/v2/)"
+        COMPOSE_ID="$(sudo /usr/libexec/osbuild-composer-test/koji-compose.py "${DISTRO_CODE}" "${ARCH}")"
+        ;;
+    "$TEST_TYPE_CLOUD_UPLOAD")
+        greenprint "Pushing compose to Koji (/api/image-builder-comoser/v2/) with cloud upload target"
+        COMPOSE_ID="$(sudo -E /usr/libexec/osbuild-composer-test/koji-compose.py "${DISTRO_CODE}" "${ARCH}" "${CLOUD_PROVIDER}" "${IMAGE_TYPE}")"
+        ;;
+    *)
+        echo "Unknown test type: ${TEST_TYPE}"
+        exit 1
+esac
+
+if [[ "$TEST_TYPE" == "$TEST_TYPE_CLOUD_UPLOAD" ]]; then
+    greenprint "Verify that image was uploaded to the cloud provider"
+
+    COMPOSE_STATUS=$(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}")
+    UPLOAD_OPTIONS=$(echo "${COMPOSE_STATUS}" | jq -r '.image_status.upload_status.options')
+
+    # Authenticate with the appropriate cloud
+    cloud_login
+
+    case $CLOUD_PROVIDER in
+        "$CLOUD_PROVIDER_AWS")
+            AMI_IMAGE_ID=$(echo "${UPLOAD_OPTIONS}" | jq -r '.ami')
+            $AWS_CMD ec2 describe-images \
+                --owners self \
+                --filters Name=image-id,Values="${AMI_IMAGE_ID}" \
+                > "${WORKDIR}/ami.json"
+            # extract the snapshot ID for the purpose of cleanup
+            AWS_SNAPSHOT_ID=$(jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId' "$WORKDIR/ami.json")
+            export AWS_SNAPSHOT_ID
+            if [[ $(jq '.Images | length' "${WORKDIR}/ami.json") -ne 1 ]]; then
+                echo "${AMI_IMAGE_ID} image not found in AWS"
+                exit 1
+            fi
+            ;;
+        "$CLOUD_PROVIDER_GCP")
+            GCP_IMAGE_NAME=$(echo "${UPLOAD_OPTIONS}" | jq -r '.image_name')
+            # The command exits with non-zero value if the image does not exist
+            $GCP_CMD compute images describe "${GCP_IMAGE_NAME}"
+            ;;
+        "$CLOUD_PROVIDER_AZURE")
+            AZURE_IMAGE_NAME=$(echo "${UPLOAD_OPTIONS}" | jq -r '.image_name')
+            # The command exits with non-zero value if the image does not exist
+            $AZURE_CMD image show --resource-group "${AZURE_RESOURCE_GROUP}" --name "${AZURE_IMAGE_NAME}"
+            ;;
+        *)
+            echo "Unknown cloud provider: ${CLOUD_PROVIDER}"
+            exit 1
+    esac
+
+    # if we got here, the image must have been found
+    greenprint "Image was SUCCESSFULLY found in the respective cloud provider environment!"
+fi
 
 greenprint "Show Koji task"
 koji --server=http://localhost:8080/kojihub taskinfo 1
diff --git a/test/data/repositories/rhel-86.json b/test/data/repositories/rhel-86.json
index c79389918..10b07a411 100644
--- a/test/data/repositories/rhel-86.json
+++ b/test/data/repositories/rhel-86.json
@@ -62,6 +62,24 @@
       "rhsm": true,
       "check_gpg": true
     },
+    {
+      "name": "rhui",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-3-20220201",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "ec2",
+        "ec2-ha",
+        "ec2-sap"
+      ]
+    },
+    {
+      "name": "rhui-azure",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220227",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "azure-rhui"
+      ]
+    },
     {
       "name": "google-compute-engine",
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/el8-x86_64-google-compute-engine-20220227",
diff --git a/test/data/repositories/rhel-87.json b/test/data/repositories/rhel-87.json
index f2b4496a9..7f8501f4e 100644
--- a/test/data/repositories/rhel-87.json
+++ b/test/data/repositories/rhel-87.json
@@ -15,6 +15,24 @@
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rt-n8.7-20220415/",
       "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n"
     },
+    {
+      "name": "rhui",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-3-20220201",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "ec2",
+        "ec2-ha",
+        "ec2-sap"
+      ]
+    },
+    {
+      "name": "rhui-azure",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220227",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "azure-rhui"
+      ]
+    },
     {
       "name": "google-compute-engine",
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el8/el8-x86_64-google-compute-engine-20220227",
diff --git a/test/data/repositories/rhel-90.json b/test/data/repositories/rhel-90.json
index 14dec2e5e..a7a81819b 100644
--- a/test/data/repositories/rhel-90.json
+++ b/test/data/repositories/rhel-90.json
@@ -69,6 +69,24 @@
       "rhsm": true,
       "check_gpg": true
     },
+    {
+      "name": "rhui",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-rhui-4-20220208/",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGIpIp4BEAC/o5e1WzLIsS6/JOQCs4XYATYTcf6B6ALzcP05G0W3uRpUQSrL\nFRKNrU8ZCelm/B+XSh2ljJNeklp2WLxYENDOsftDXGoyLr2hEkI5OyK267IHhFNJ\ng+BN+T5Cjh4ZiiWij6o9F7x2ZpxISE9M4iI80rwSv1KOnGSw5j2zD2EwoMjTVyVE\n/t3s5XJxnDclB7ZqL+cgjv0mWUY/4+b/OoRTkhq7b8QILuZp75Y64pkrndgakm1T\n8mAGXV02mEzpNj9DyAJdUqa11PIhMJMxxHOGHJ8CcHZ2NJL2e7yJf4orTj+cMhP5\nLzJcVlaXnQYu8Zkqa0V6J1Qdj8ZXL72QsmyicRYXAtK9Jm5pvBHuYU2m6Ja7dBEB\nVkhe7lTKhAjkZC5ErPmANNS9kPdtXCOpwN1lOnmD2m04hks3kpH9OTX7RkTFUSws\neARAfRID6RLfi59B9lmAbekecnsMIFMx7qR7ZKyQb3GOuZwNYOaYFevuxusSwCHv\n4FtLDIhk+Fge+EbPdEva+VLJeMOb02gC4V/cX/oFoPkxM1A5LHjkuAM+aFLAiIRd\nNp/tAPWk1k6yc+FqkcDqOttbP4ciiXb9JPtmzTCbJD8lgH0rGp8ufyMXC9x7/dqX\nTjsiGzyvlMnrkKB4GL4DqRFl8LAR02A3846DD8CAcaxoXggL2bJCU2rgUQARAQAB\ntDVSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5IDMpIDxzZWN1cml0eUByZWRo\nYXQuY29tPokCUgQTAQgAPBYhBH5GJCWMQGU11W1vE1BU5KRaY0CzBQJiKSKeAhsD\nBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRBQVOSkWmNAsyBfEACuTN/X\nYR+QyzeRw0pXcTvMqzNE4DKKr97hSQEwZH1/v1PEPs5O3psuVUm2iam7bqYwG+ry\nEskAgMHi8AJmY0lioQD5/LTSLTrM8UyQnU3g17DHau1NHIFTGyaW4a7xviU4C2+k\nc6X0u1CPHI1U4Q8prpNcfLsldaNYlsVZtUtYSHKPAUcswXWliW7QYjZ5tMSbu8jR\nOMOc3mZuf0fcVFNu8+XSpN7qLhRNcPv+FCNmk/wkaQfH4Pv+jVsOgHqkV3aLqJeN\nkNUnpyEKYkNqo7mNfNVWOcl+Z1KKKwSkIi3vg8maC7rODsy6IX+Y96M93sqYDQom\naaWue2gvw6thEoH4SaCrCL78mj2YFpeg1Oew4QwVcBnt68KOPfL9YyoOicNs4Vuu\nfb/vjU2ONPZAeepIKA8QxCETiryCcP43daqThvIgdbUIiWne3gae6eSj0EuUPoYe\nH5g2Lw0qdwbHIOxqp2kvN96Ii7s1DK3VyhMt/GSPCxRnDRJ8oQKJ2W/I1IT5VtiU\nzMjjq5JcYzRPzHDxfVzT9CLeU/0XQ+2OOUAiZKZ0dzSyyVn8xbpviT7iadvjlQX3\nCINaPB+d2Kxa6uFWh+ZYOLLAgZ9B8NKutUHpXN66YSfe79xFBSFWKkJ8cSIMk13/\nIfs7ApKlKCCRDpwoDqx/sjIaj1cpOfLHYjnefg==\n=UZd/\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "ec2",
+        "ec2-ha",
+        "ec2-sap"
+      ]
+    },
+    {
+      "name": "rhui-azure",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-rhui-azure-20220518",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "azure-rhui"
+      ]
+    },
     {
       "name": "google-compute-engine",
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/el9-x86_64-google-compute-engine-20220322",
diff --git a/test/data/repositories/rhel-91.json b/test/data/repositories/rhel-91.json
index e165650ad..7ced996b9 100644
--- a/test/data/repositories/rhel-91.json
+++ b/test/data/repositories/rhel-91.json
@@ -51,6 +51,25 @@
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-rt-n9.1-20220415/",
       "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBGIpIp4BEAC/o5e1WzLIsS6/JOQCs4XYATYTcf6B6ALzcP05G0W3uRpUQSrL\nFRKNrU8ZCelm/B+XSh2ljJNeklp2WLxYENDOsftDXGoyLr2hEkI5OyK267IHhFNJ\ng+BN+T5Cjh4ZiiWij6o9F7x2ZpxISE9M4iI80rwSv1KOnGSw5j2zD2EwoMjTVyVE\n/t3s5XJxnDclB7ZqL+cgjv0mWUY/4+b/OoRTkhq7b8QILuZp75Y64pkrndgakm1T\n8mAGXV02mEzpNj9DyAJdUqa11PIhMJMxxHOGHJ8CcHZ2NJL2e7yJf4orTj+cMhP5\nLzJcVlaXnQYu8Zkqa0V6J1Qdj8ZXL72QsmyicRYXAtK9Jm5pvBHuYU2m6Ja7dBEB\nVkhe7lTKhAjkZC5ErPmANNS9kPdtXCOpwN1lOnmD2m04hks3kpH9OTX7RkTFUSws\neARAfRID6RLfi59B9lmAbekecnsMIFMx7qR7ZKyQb3GOuZwNYOaYFevuxusSwCHv\n4FtLDIhk+Fge+EbPdEva+VLJeMOb02gC4V/cX/oFoPkxM1A5LHjkuAM+aFLAiIRd\nNp/tAPWk1k6yc+FqkcDqOttbP4ciiXb9JPtmzTCbJD8lgH0rGp8ufyMXC9x7/dqX\nTjsiGzyvlMnrkKB4GL4DqRFl8LAR02A3846DD8CAcaxoXggL2bJCU2rgUQARAQAB\ntDVSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5IDMpIDxzZWN1cml0eUByZWRo\nYXQuY29tPokCUgQTAQgAPBYhBH5GJCWMQGU11W1vE1BU5KRaY0CzBQJiKSKeAhsD\nBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRBQVOSkWmNAsyBfEACuTN/X\nYR+QyzeRw0pXcTvMqzNE4DKKr97hSQEwZH1/v1PEPs5O3psuVUm2iam7bqYwG+ry\nEskAgMHi8AJmY0lioQD5/LTSLTrM8UyQnU3g17DHau1NHIFTGyaW4a7xviU4C2+k\nc6X0u1CPHI1U4Q8prpNcfLsldaNYlsVZtUtYSHKPAUcswXWliW7QYjZ5tMSbu8jR\nOMOc3mZuf0fcVFNu8+XSpN7qLhRNcPv+FCNmk/wkaQfH4Pv+jVsOgHqkV3aLqJeN\nkNUnpyEKYkNqo7mNfNVWOcl+Z1KKKwSkIi3vg8maC7rODsy6IX+Y96M93sqYDQom\naaWue2gvw6thEoH4SaCrCL78mj2YFpeg1Oew4QwVcBnt68KOPfL9YyoOicNs4Vuu\nfb/vjU2ONPZAeepIKA8QxCETiryCcP43daqThvIgdbUIiWne3gae6eSj0EuUPoYe\nH5g2Lw0qdwbHIOxqp2kvN96Ii7s1DK3VyhMt/GSPCxRnDRJ8oQKJ2W/I1IT5VtiU\nzMjjq5JcYzRPzHDxfVzT9CLeU/0XQ+2OOUAiZKZ0dzSyyVn8xbpviT7iadvjlQX3\nCINaPB+d2Kxa6uFWh+ZYOLLAgZ9B8NKutUHpXN66YSfe79xFBSFWKkJ8cSIMk13/\nIfs7ApKlKCCRDpwoDqx/sjIaj1cpOfLHYjnefg==\n=UZd/\n-----END PGP PUBLIC KEY BLOCK-----\n"
     },
+    {
+      "name": "rhui",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-rhui-4-20220324/",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "check_gpg": true,
+      "image_type_tags": [
+        "ec2",
+        "ec2-ha",
+        "ec2-sap"
+      ]
+    },
+    {
+      "name": "rhui-azure",
+      "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-rhui-azure-20220518",
+      "gpgkey": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBErgSTsBEACh2A4b0O9t+vzC9VrVtL1AKvUWi9OPCjkvR7Xd8DtJxeeMZ5eF\n0HtzIG58qDRybwUe89FZprB1ffuUKzdE+HcL3FbNWSSOXVjZIersdXyH3NvnLLLF\n0DNRB2ix3bXG9Rh/RXpFsNxDp2CEMdUvbYCzE79K1EnUTVh1L0Of023FtPSZXX0c\nu7Pb5DI5lX5YeoXO6RoodrIGYJsVBQWnrWw4xNTconUfNPk0EGZtEnzvH2zyPoJh\nXGF+Ncu9XwbalnYde10OCvSWAZ5zTCpoLMTvQjWpbCdWXJzCm6G+/hx9upke546H\n5IjtYm4dTIVTnc3wvDiODgBKRzOl9rEOCIgOuGtDxRxcQkjrC+xvg5Vkqn7vBUyW\n9pHedOU+PoF3DGOM+dqv+eNKBvh9YF9ugFAQBkcG7viZgvGEMGGUpzNgN7XnS1gj\n/DPo9mZESOYnKceve2tIC87p2hqjrxOHuI7fkZYeNIcAoa83rBltFXaBDYhWAKS1\nPcXS1/7JzP0ky7d0L6Xbu/If5kqWQpKwUInXtySRkuraVfuK3Bpa+X1XecWi24JY\nHVtlNX025xx1ewVzGNCTlWn1skQN2OOoQTV4C8/qFpTW6DTWYurd4+fE0OJFJZQF\nbuhfXYwmRlVOgN5i77NTIJZJQfYFj38c/Iv5vZBPokO6mffrOTv3MHWVgQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChyZWxlYXNlIGtleSAyKSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjYEEwECACAFAkrgSTsCGwMGCwkIBwMCBBUCCAMEFgIDAQIeAQIXgAAK\nCRAZni+R/UMdUWzpD/9s5SFR/ZF3yjY5VLUFLMXIKUztNN3oc45fyLdTI3+UClKC\n2tEruzYjqNHhqAEXa2sN1fMrsuKec61Ll2NfvJjkLKDvgVIh7kM7aslNYVOP6BTf\nC/JJ7/ufz3UZmyViH/WDl+AYdgk3JqCIO5w5ryrC9IyBzYv2m0HqYbWfphY3uHw5\nun3ndLJcu8+BGP5F+ONQEGl+DRH58Il9Jp3HwbRa7dvkPgEhfFR+1hI+Btta2C7E\n0/2NKzCxZw7Lx3PBRcU92YKyaEihfy/aQKZCAuyfKiMvsmzs+4poIX7I9NQCJpyE\nIGfINoZ7VxqHwRn/d5mw2MZTJjbzSf+Um9YJyA0iEEyD6qjriWQRbuxpQXmlAJbh\n8okZ4gbVFv1F8MzK+4R8VvWJ0XxgtikSo72fHjwha7MAjqFnOq6eo6fEC/75g3NL\nGht5VdpGuHk0vbdENHMC8wS99e5qXGNDued3hlTavDMlEAHl34q2H9nakTGRF5Ki\nJUfNh3DVRGhg8cMIti21njiRh7gyFI2OccATY7bBSr79JhuNwelHuxLrCFpY7V25\nOFktl15jZJaMxuQBqYdBgSay2G0U6D1+7VsWufpzd/Abx1/c3oi9ZaJvW22kAggq\ndzdA27UUYjWvx42w9menJwh/0jeQcTecIUd0d0rFcw/c1pvgMMl/Q73yzKgKYw==\n=zbHE\n-----END PGP PUBLIC KEY BLOCK-----\n-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFsy23UBEACUKSphFEIEvNpy68VeW4Dt6qv+mU6am9a2AAl10JANLj1oqWX+\noYk3en1S6cVe2qehSL5DGVa3HMUZkP3dtbD4SgzXzxPodebPcr4+0QNWigkUisri\nXGL5SCEcOP30zDhZvg+4mpO2jMi7Kc1DLPzBBkgppcX91wa0L1pQzBcvYMPyV/Dh\nKbQHR75WdkP6OA2JXdfC94nxYq+2e0iPqC1hCP3Elh+YnSkOkrawDPmoB1g4+ft/\nxsiVGVy/W0ekXmgvYEHt6si6Y8NwXgnTMqxeSXQ9YUgVIbTpsxHQKGy76T5lMlWX\n4LCOmEVomBJg1SqF6yi9Vu8TeNThaDqT4/DddYInd0OO69s0kGIXalVgGYiW2HOD\nx2q5R1VGCoJxXomz+EbOXY+HpKPOHAjU0DB9MxbU3S248LQ69nIB5uxysy0PSco1\nsdZ8sxRNQ9Dw6on0Nowx5m6Thefzs5iK3dnPGBqHTT43DHbnWc2scjQFG+eZhe98\nEll/kb6vpBoY4bG9/wCG9qu7jj9Z+BceCNKeHllbezVLCU/Hswivr7h2dnaEFvPD\nO4GqiWiwOF06XaBMVgxA8p2HRw0KtXqOpZk+o+sUvdPjsBw42BB96A1yFX4jgFNA\nPyZYnEUdP6OOv9HSjnl7k/iEkvHq/jGYMMojixlvXpGXhnt5jNyc4GSUJQARAQAB\ntDNSZWQgSGF0LCBJbmMuIChhdXhpbGlhcnkga2V5KSA8c2VjdXJpdHlAcmVkaGF0\nLmNvbT6JAjkEEwECACMFAlsy23UCGwMHCwkIBwMCAQYVCAIJCgsEFgIDAQIeAQIX\ngAAKCRD3b2bD1AgnknqOD/9fB2ASuG2aJIiap4kK58R+RmOVM4qgclAnaG57+vjI\nnKvyfV3NH/keplGNRxwqHekfPCqvkpABwhdGEXIE8ILqnPewIMr6PZNZWNJynZ9i\neSMzVuCG7jDoGyQ5/6B0f6xeBtTeBDiRl7+Alehet1twuGL1BJUYG0QuLgcEzkaE\n/gkuumeVcazLzz7L12D22nMk66GxmgXfqS5zcbqOAuZwaA6VgSEgFdV2X2JU79zS\nBQJXv7NKc+nDXFG7M7EHjY3Rma3HXkDbkT8bzh9tJV7Z7TlpT829pStWQyoxKCVq\nsEX8WsSapTKA3P9YkYCwLShgZu4HKRFvHMaIasSIZWzLu+RZH/4yyHOhj0QB7XMY\neHQ6fGSbtJ+K6SrpHOOsKQNAJ0hVbSrnA1cr5+2SDfel1RfYt0W9FA6DoH/S5gAR\ndzT1u44QVwwp3U+eFpHphFy//uzxNMtCjjdkpzhYYhOCLNkDrlRPb+bcoL/6ePSr\n016PA7eEnuC305YU1Ml2WcCn7wQV8x90o33klJmEkWtXh3X39vYtI4nCPIvZn1eP\nVy+F+wWt4vN2b8oOdlzc2paOembbCo2B+Wapv5Y9peBvlbsDSgqtJABfK8KQq/jK\nYl3h5elIa1I3uNfczeHOnf1enLOUOlq630yeM/yHizz99G1g+z/guMh5+x/OHraW\niA==\n=+Gxh\n-----END PGP PUBLIC KEY BLOCK-----\n",
+      "image_type_tags": [
+        "azure-rhui"
+      ]
+    },
     {
       "name": "google-compute-engine",
       "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/public/el9/el9-x86_64-google-compute-engine-20220322",