Repository: osbuild/osbuild-composer
Pull request #2659 (2022-05-17 15:46:03)

cloudapi: standardize format of url strings
Small fix, add format: uri to base_url for the sake of consistency
across url strings.


This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/



Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 41827d944..2351b82f3 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -402,11 +402,18 @@ Rebase OSTree:
       - *fedora_runners
       - *rhel_runners
 
-koji.sh:
+koji.sh (cloudapi):
   extends: .integration
   variables:
     SCRIPT: koji.sh
 
+# internal composer still uses kojiapi, so keep testing it for now
+koji.sh (kojiapi):
+  extends: .integration
+  variables:
+    COMPOSER_API: "false"
+    SCRIPT: koji.sh
+
 aws.sh:
   extends: .integration
   variables:
diff --git a/cmd/gen-manifests/main.go b/cmd/gen-manifests/main.go
index d9887859b..0a1ba1170 100644
--- a/cmd/gen-manifests/main.go
+++ b/cmd/gen-manifests/main.go
@@ -24,6 +24,8 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
+const cacheRoot = "/tmp/rpmmd"
+
 type repository struct {
 	Name           string   `json:"name"`
 	BaseURL        string   `json:"baseurl,omitempty"`
@@ -98,7 +100,7 @@ func makeManifestJob(name string, imgType distro.ImageType, cr composeRequest, d
 		return strings.Replace(s, "-", "_", -1)
 	}
 	filename := fmt.Sprintf("%s-%s-%s-boot.json", u(distroName), u(archName), u(name))
-	cacheDir := filepath.Join("/tmp", "rpmmd", archName+distribution.Name())
+	cacheDir := filepath.Join(cacheRoot, archName+distribution.Name())
 
 	options := distro.ImageOptions{Size: 0}
 	if cr.OSTree != nil {
@@ -378,11 +380,14 @@ func main() {
 		wq.submitJob(j)
 	}
 	errs := wq.wait()
+	exit := 0
 	if len(errs) > 0 {
 		fmt.Printf("Encountered %d errors:\n", len(errs))
 		for idx, err := range errs {
 			fmt.Printf("%3d: %s\n", idx, err.Error())
 		}
-		os.Exit(1)
+		exit = 1
 	}
+	fmt.Printf("RPM metadata cache kept in %s\n", cacheRoot)
+	os.Exit(exit)
 }
diff --git a/cmd/osbuild-service-maintenance/db.go b/cmd/osbuild-service-maintenance/db.go
index 5a56e05fb..5f8f8ebbc 100644
--- a/cmd/osbuild-service-maintenance/db.go
+++ b/cmd/osbuild-service-maintenance/db.go
@@ -7,6 +7,7 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/osbuild/osbuild-composer/internal/jobqueue/dbjobqueue"
+	"github.com/osbuild/osbuild-composer/internal/worker"
 )
 
 func DBCleanup(dbURL string, dryRun bool, cutoff time.Time) error {
@@ -17,7 +18,7 @@ func DBCleanup(dbURL string, dryRun bool, cutoff time.Time) error {
 
 	// The results of these jobs take up the most space and can contain sensitive data. Delete
 	// them after a while.
-	jobsByType, err := jobs.JobsUptoByType([]string{"manifest-id-only", "depsolve"}, cutoff)
+	jobsByType, err := jobs.JobsUptoByType([]string{worker.JobTypeManifestIDOnly, worker.JobTypeDepsolve}, cutoff)
 	if err != nil {
 		return fmt.Errorf("Error querying jobs: %v", err)
 	}
diff --git a/cmd/osbuild-worker/jobimpl-koji-finalize.go b/cmd/osbuild-worker/jobimpl-koji-finalize.go
index 7a21a5892..9f40ceb5c 100644
--- a/cmd/osbuild-worker/jobimpl-koji-finalize.go
+++ b/cmd/osbuild-worker/jobimpl-koji-finalize.go
@@ -9,6 +9,7 @@ import (
 
 	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild2"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/upload/koji"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
@@ -90,29 +91,7 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 		return err
 	}
 
-	initArgs, osbuildKojiResults, err := extractDynamicArgs(job)
-	if err != nil {
-		return err
-	}
-
-	// Check the dependencies early. Fail the koji build if any of them failed.
-	if hasFailedDependency(*initArgs, osbuildKojiResults) {
-		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)
-		}
-		return nil
-	}
-
 	build := koji.ImageBuild{
-		BuildID:   initArgs.BuildID,
 		TaskID:    args.TaskID,
 		Name:      args.Name,
 		Version:   args.Version,
@@ -121,62 +100,183 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 		EndTime:   time.Now().Unix(),
 	}
 
+	var initArgs *worker.KojiInitJobResult
 	var buildRoots []koji.BuildRoot
 	var images []koji.Image
-	for i, buildArgs := range osbuildKojiResults {
-		buildRPMs := make([]rpmmd.RPM, 0)
-		// collect packages from stages in build pipelines
-		for _, plName := range buildArgs.PipelineNames.Build {
-			buildPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
-			buildRPMs = append(buildRPMs, osbuild.OSBuildMetadataToRPMs(buildPipelineMd)...)
+
+	isOldKojiCompose, err := isOldKojiComposeReq(job)
+	if err != nil {
+		return err
+	}
+
+	// TODO: remove eventually. Kept for backward compatibility.
+	if isOldKojiCompose {
+		var osbuildKojiResults []worker.OSBuildKojiJobResult
+		initArgs, osbuildKojiResults, err = extractDynamicArgsOld(job)
+		if err != nil {
+			return err
 		}
-		// this dedupe is usually not necessary since we generally only have
-		// one rpm stage in one build pipeline, but it's not invalid to have
-		// multiple
-		buildRPMs = rpmmd.DeduplicateRPMs(buildRPMs)
-		buildRoots = append(buildRoots, koji.BuildRoot{
-			ID: uint64(i),
-			Host: koji.Host{
-				Os:   buildArgs.HostOS,
-				Arch: buildArgs.Arch,
-			},
-			ContentGenerator: koji.ContentGenerator{
-				Name:    "osbuild",
-				Version: "0", // TODO: put the correct version here
-			},
-			Container: koji.Container{
-				Type: "none",
-				Arch: buildArgs.Arch,
-			},
-			Tools: []koji.Tool{},
-			RPMs:  buildRPMs,
-		})
-
-		// collect packages from stages in payload pipelines
-		imageRPMs := make([]rpmmd.RPM, 0)
-		for _, plName := range buildArgs.PipelineNames.Payload {
-			payloadPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
-			imageRPMs = append(imageRPMs, osbuild.OSBuildMetadataToRPMs(payloadPipelineMd)...)
+		build.BuildID = initArgs.BuildID
+
+		// Check the dependencies early. Fail the koji build if any of them failed.
+		if hasFailedDependencyOld(*initArgs, osbuildKojiResults) {
+			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)
+			}
+			return nil
 		}
 
-		// deduplicate
-		imageRPMs = rpmmd.DeduplicateRPMs(imageRPMs)
-
-		images = append(images, koji.Image{
-			BuildRootID:  uint64(i),
-			Filename:     args.KojiFilenames[i],
-			FileSize:     buildArgs.ImageSize,
-			Arch:         buildArgs.Arch,
-			ChecksumType: "md5",
-			MD5:          buildArgs.ImageHash,
-			Type:         "image",
-			RPMs:         imageRPMs,
-			Extra: koji.ImageExtra{
-				Info: koji.ImageExtraInfo{
+		for i, buildArgs := range osbuildKojiResults {
+			buildRPMs := make([]rpmmd.RPM, 0)
+			// collect packages from stages in build pipelines
+			for _, plName := range buildArgs.PipelineNames.Build {
+				buildPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
+				buildRPMs = append(buildRPMs, osbuild.OSBuildMetadataToRPMs(buildPipelineMd)...)
+			}
+			// this dedupe is usually not necessary since we generally only have
+			// one rpm stage in one build pipeline, but it's not invalid to have
+			// multiple
+			buildRPMs = rpmmd.DeduplicateRPMs(buildRPMs)
+			buildRoots = append(buildRoots, koji.BuildRoot{
+				ID: uint64(i),
+				Host: koji.Host{
+					Os:   buildArgs.HostOS,
 					Arch: buildArgs.Arch,
 				},
-			},
-		})
+				ContentGenerator: koji.ContentGenerator{
+					Name:    "osbuild",
+					Version: "0", // TODO: put the correct version here
+				},
+				Container: koji.Container{
+					Type: "none",
+					Arch: buildArgs.Arch,
+				},
+				Tools: []koji.Tool{},
+				RPMs:  buildRPMs,
+			})
+
+			// collect packages from stages in payload pipelines
+			imageRPMs := make([]rpmmd.RPM, 0)
+			for _, plName := range buildArgs.PipelineNames.Payload {
+				payloadPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
+				imageRPMs = append(imageRPMs, osbuild.OSBuildMetadataToRPMs(payloadPipelineMd)...)
+			}
+
+			// deduplicate
+			imageRPMs = rpmmd.DeduplicateRPMs(imageRPMs)
+
+			images = append(images, koji.Image{
+				BuildRootID:  uint64(i),
+				Filename:     args.KojiFilenames[i],
+				FileSize:     buildArgs.ImageSize,
+				Arch:         buildArgs.Arch,
+				ChecksumType: "md5",
+				MD5:          buildArgs.ImageHash,
+				Type:         "image",
+				RPMs:         imageRPMs,
+				Extra: koji.ImageExtra{
+					Info: koji.ImageExtraInfo{
+						Arch: buildArgs.Arch,
+					},
+				},
+			})
+		}
+	} else {
+		var osbuildResults []worker.OSBuildJobResult
+		initArgs, osbuildResults, err = extractDynamicArgs(job)
+		if err != nil {
+			return err
+		}
+		build.BuildID = initArgs.BuildID
+
+		// Check the dependencies early. Fail the koji build if any of them failed.
+		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)
+			}
+			return nil
+		}
+
+		for i, buildArgs := range osbuildResults {
+			buildRPMs := make([]rpmmd.RPM, 0)
+			// collect packages from stages in build pipelines
+			for _, plName := range buildArgs.PipelineNames.Build {
+				buildPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
+				buildRPMs = append(buildRPMs, osbuild.OSBuildMetadataToRPMs(buildPipelineMd)...)
+			}
+			// this dedupe is usually not necessary since we generally only have
+			// one rpm stage in one build pipeline, but it's not invalid to have
+			// 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")
+			}
+			kojiTarget := buildArgs.TargetResults[0]
+			kojiTargetOptions := kojiTarget.Options.(target.KojiTargetResultOptions)
+
+			buildRoots = append(buildRoots, koji.BuildRoot{
+				ID: uint64(i),
+				Host: koji.Host{
+					Os:   buildArgs.HostOS,
+					Arch: buildArgs.Arch,
+				},
+				ContentGenerator: koji.ContentGenerator{
+					Name:    "osbuild",
+					Version: "0", // TODO: put the correct version here
+				},
+				Container: koji.Container{
+					Type: "none",
+					Arch: buildArgs.Arch,
+				},
+				Tools: []koji.Tool{},
+				RPMs:  buildRPMs,
+			})
+
+			// collect packages from stages in payload pipelines
+			imageRPMs := make([]rpmmd.RPM, 0)
+			for _, plName := range buildArgs.PipelineNames.Payload {
+				payloadPipelineMd := buildArgs.OSBuildOutput.Metadata[plName]
+				imageRPMs = append(imageRPMs, osbuild.OSBuildMetadataToRPMs(payloadPipelineMd)...)
+			}
+
+			// deduplicate
+			imageRPMs = rpmmd.DeduplicateRPMs(imageRPMs)
+
+			images = append(images, koji.Image{
+				BuildRootID:  uint64(i),
+				Filename:     args.KojiFilenames[i],
+				FileSize:     kojiTargetOptions.ImageSize,
+				Arch:         buildArgs.Arch,
+				ChecksumType: "md5",
+				MD5:          kojiTargetOptions.ImageMD5,
+				Type:         "image",
+				RPMs:         imageRPMs,
+				Extra: koji.ImageExtra{
+					Info: koji.ImageExtraInfo{
+						Arch: buildArgs.Arch,
+					},
+				},
+			})
+		}
 	}
 
 	var result worker.KojiFinalizeJobResult
@@ -195,7 +295,7 @@ func (impl *KojiFinalizeJobImpl) Run(job worker.Job) error {
 
 // Extracts dynamic args of the koji-finalize job. Returns an error if they
 // cannot be unmarshalled.
-func extractDynamicArgs(job worker.Job) (*worker.KojiInitJobResult, []worker.OSBuildKojiJobResult, error) {
+func extractDynamicArgsOld(job worker.Job) (*worker.KojiInitJobResult, []worker.OSBuildKojiJobResult, error) {
 	var kojiInitResult worker.KojiInitJobResult
 	err := job.DynamicArgs(0, &kojiInitResult)
 	if err != nil {
@@ -214,8 +314,55 @@ func extractDynamicArgs(job worker.Job) (*worker.KojiInitJobResult, []worker.OSB
 	return &kojiInitResult, osbuildKojiResults, nil
 }
 
+// Extracts dynamic args of the koji-finalize job. Returns an error if they
+// cannot be unmarshalled.
+func extractDynamicArgs(job worker.Job) (*worker.KojiInitJobResult, []worker.OSBuildJobResult, error) {
+	var kojiInitResult worker.KojiInitJobResult
+	err := job.DynamicArgs(0, &kojiInitResult)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	osbuildResults := make([]worker.OSBuildJobResult, job.NDynamicArgs()-1)
+
+	for i := 1; i < job.NDynamicArgs(); i++ {
+		err = job.DynamicArgs(i, &osbuildResults[i-1])
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+
+	return &kojiInitResult, osbuildResults, nil
+}
+
+// Tests the first osbuild job result for specific values and decides
+// if it is a `worker.OSBuildKojiJobResult` or `worker.OSBuildJobResult`.
+// In case of `worker.OSBuildKojiJobResult` returns `true`.
+// In case of 'worker.OSBuildJobResult` returns `false`.
+func isOldKojiComposeReq(job worker.Job) (bool, error) {
+	if job.NDynamicArgs() < 2 {
+		return false, fmt.Errorf("error: koji-finalize job does not have any osbuild job dynamic arg attached")
+	}
+
+	var osbuildKojiResult worker.OSBuildKojiJobResult
+	err := job.DynamicArgs(1, &osbuildKojiResult)
+	if err != nil {
+		return false, err
+	}
+
+	// The decision is based on the fact if all result values specific to
+	// `worker.OSBuildKojiJobResult` are set to meaningful values or not.
+	// The assumption is that the default type values are not meaningful.
+	if osbuildKojiResult.Arch == "" || osbuildKojiResult.HostOS == "" ||
+		osbuildKojiResult.ImageHash == "" || osbuildKojiResult.ImageSize == 0 {
+		return false, nil
+	}
+
+	return true, nil
+}
+
 // Returns true if any of koji-finalize dependencies failed.
-func hasFailedDependency(kojiInitResult worker.KojiInitJobResult, osbuildKojiResults []worker.OSBuildKojiJobResult) bool {
+func hasFailedDependencyOld(kojiInitResult worker.KojiInitJobResult, osbuildKojiResults []worker.OSBuildKojiJobResult) bool {
 	if kojiInitResult.JobError != nil {
 		return true
 	}
@@ -229,3 +376,19 @@ func hasFailedDependency(kojiInitResult worker.KojiInitJobResult, osbuildKojiRes
 	}
 	return false
 }
+
+// Returns true if any of koji-finalize dependencies failed.
+func hasFailedDependency(kojiInitResult worker.KojiInitJobResult, osbuildResults []worker.OSBuildJobResult) bool {
+	if kojiInitResult.JobError != nil {
+		return true
+	}
+
+	for _, r := range osbuildResults {
+		// No `OSBuildOutput` implies failure: either osbuild crashed or
+		// rejected the input (manifest or command line arguments)
+		if r.OSBuildOutput == nil || !r.OSBuildOutput.Success || r.JobError != nil {
+			return true
+		}
+	}
+	return false
+}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild-koji.go b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
index 0a656ac4e..2bb4d0875 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild-koji.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
@@ -10,7 +10,6 @@ import (
 	"github.com/sirupsen/logrus"
 
 	"github.com/osbuild/osbuild-composer/internal/common"
-	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/upload/koji"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
@@ -101,7 +100,7 @@ func (impl *OSBuildKojiJobImpl) Run(job worker.Job) error {
 	}
 
 	result.Arch = common.CurrentArch()
-	result.HostOS, err = distro.GetRedHatRelease()
+	result.HostOS, err = common.GetRedHatRelease()
 	if err != nil {
 		return err
 	}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 71e72755b..00d5529e3 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -5,9 +5,9 @@ import (
 	"crypto/rand"
 	"fmt"
 	"io/ioutil"
-	"log"
 	"math"
 	"math/big"
+	"net/url"
 	"os"
 	"path"
 	"strings"
@@ -39,14 +39,15 @@ type S3Configuration struct {
 }
 
 type OSBuildJobImpl struct {
-	Store       string
-	Output      string
-	KojiServers map[string]koji.GSSAPICredentials
-	GCPCreds    string
-	AzureCreds  *azure.Credentials
-	AWSCreds    string
-	AWSBucket   string
-	S3Config    S3Configuration
+	Store                  string
+	Output                 string
+	KojiServers            map[string]koji.GSSAPICredentials
+	KojiRelaxTimeoutFactor uint
+	GCPCreds               string
+	AzureCreds             *azure.Credentials
+	AWSCreds               string
+	AWSBucket              string
+	S3Config               S3Configuration
 }
 
 // Returns an *awscloud.AWS object with the credentials of the request. If they
@@ -237,8 +238,16 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			Success: false,
 		},
 		UploadStatus: "failure",
+		Arch:         common.CurrentArch(),
 	}
 
+	hostOS, err := common.GetRedHatRelease()
+	if err != nil {
+		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorBuildJob, err.Error())
+		return nil
+	}
+	osbuildJobResult.HostOS = hostOS
+
 	var outputDirectory string
 
 	// In all cases it is necessary to report result back to osbuild-composer worker API.
@@ -256,7 +265,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 		}
 	}()
 
-	outputDirectory, err := ioutil.TempDir(impl.Output, job.Id().String()+"-*")
+	outputDirectory, err = ioutil.TempDir(impl.Output, job.Id().String()+"-*")
 	if err != nil {
 		return fmt.Errorf("error creating temporary output directory: %v", err)
 	}
@@ -272,7 +281,17 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 	if len(args.Manifest) == 0 {
 		if job.NDynamicArgs() > 0 {
 			var manifestJR worker.ManifestJobByIDResult
-			err = job.DynamicArgs(0, &manifestJR)
+			if job.NDynamicArgs() == 1 {
+				// Classic case of a compose request with the ManifestJobByID job as the single dependency
+				err = job.DynamicArgs(0, &manifestJR)
+			} else if job.NDynamicArgs() > 1 && args.ManifestDynArgsIdx != nil {
+				// Case when the job has multiple dependencies, but the manifest is not part of the static job arguments,
+				// but rather in the dynamic arguments (e.g. from ManifestJobByID job).
+				if *args.ManifestDynArgsIdx > job.NDynamicArgs()-1 {
+					panic("ManifestDynArgsIdx is out of range of the number of dynamic job arguments")
+				}
+				err = job.DynamicArgs(*args.ManifestDynArgsIdx, &manifestJR)
+			}
 			if err != nil {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, "Error parsing dynamic args")
 				return err
@@ -284,15 +303,30 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				return nil
 			}
 			args.Manifest = manifestJR.Manifest
-			if len(args.Manifest) == 0 {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorEmptyManifest, "Received empty manifest")
-				return nil
-			}
-		} else {
+		}
+
+		if len(args.Manifest) == 0 {
 			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorEmptyManifest, "Job has no manifest")
 			return nil
 		}
 	}
+
+	// Explicitly check that none of the job dependencies failed.
+	// This covers mainly the case when there are more than one job dependencies.
+	for idx := 0; idx < job.NDynamicArgs(); idx++ {
+		var jobResult worker.JobResult
+		err = job.DynamicArgs(idx, &jobResult)
+		if err != nil {
+			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, "Error parsing dynamic args")
+			return err
+		}
+
+		if jobResult.JobError != nil {
+			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorJobDependency, "Job dependency failed")
+			return nil
+		}
+	}
+
 	// copy pipeline info to the result
 	osbuildJobResult.PipelineNames = args.PipelineNames
 
@@ -685,6 +719,54 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				ImageName: args.Targets[0].ImageName,
 			}))
 
+			osbuildJobResult.Success = true
+			osbuildJobResult.UploadStatus = "success"
+		case *target.KojiTargetOptions:
+			kojiServerURL, err := url.Parse(options.Server)
+			if err != nil {
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to parse Koji server URL: %v", err))
+				return nil
+			}
+
+			creds, exists := impl.KojiServers[kojiServerURL.Hostname()]
+			if !exists {
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("Koji server has not been configured: %s", kojiServerURL.Hostname()))
+				return nil
+			}
+
+			kojiTransport := koji.CreateKojiTransport(impl.KojiRelaxTimeoutFactor)
+
+			kojiAPI, err := koji.NewFromGSSAPI(options.Server, &creds, kojiTransport)
+			if err != nil {
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to authenticate with Koji server %q: %v", kojiServerURL.Hostname(), err))
+				return nil
+			}
+			logWithId.Infof("[Koji] 🔑 Authenticated with %q", kojiServerURL.Hostname())
+			defer func() {
+				err := kojiAPI.Logout()
+				if err != nil {
+					logWithId.Warnf("[Koji] logout failed: %v", err)
+				}
+			}()
+			file, err := os.Open(path.Join(outputDirectory, exportPath, args.ImageName))
+			if err != nil {
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiBuild, fmt.Sprintf("failed to open the image for reading: %v", err))
+				return nil
+			}
+			defer file.Close()
+
+			logWithId.Info("[Koji] ⬆ Uploading the image")
+			imageHash, imageSize, err := kojiAPI.Upload(file, options.UploadDirectory, options.Filename)
+			if err != nil {
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+			}
+			logWithId.Info("[Koji] 🎉 Image successfully uploaded")
+
+			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+				ImageMD5:  imageHash,
+				ImageSize: imageSize,
+			}))
+
 			osbuildJobResult.Success = true
 			osbuildJobResult.UploadStatus = "success"
 		case *target.OCITargetOptions:
@@ -701,8 +783,8 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
 				return nil
 			}
-			log.Print("[OCI] 🔑 Logged in OCI")
-			log.Print("[OCI] ⬆ Uploading the image")
+			logWithId.Info("[OCI] 🔑 Logged in OCI")
+			logWithId.Info("[OCI] ⬆ Uploading the image")
 			file, err := os.Open(path.Join(outputDirectory, exportPath, options.FileName))
 			if err != nil {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
@@ -722,7 +804,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
 				return nil
 			}
-			log.Print("[OCI] 🎉 Image uploaded and registered!")
+			logWithId.Info("[OCI] 🎉 Image uploaded and registered!")
 
 			osbuildJobResult.TargetResults = append(
 				osbuildJobResult.TargetResults,
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index a3e029f90..c4153ec6d 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -170,7 +170,7 @@ func RequestAndRunJob(client *worker.Client, acceptedJobTypes []string, jobImpls
 
 	// Depsolve requests needs reactivity, since setting the protection can take up to 6s to timeout if the worker isn't
 	// in an AWS env, disable this setting for them.
-	if job.Type() != "depsolve" {
+	if job.Type() != worker.JobTypeDepsolve {
 		setProtection(true)
 		defer setProtection(false)
 	}
@@ -227,7 +227,7 @@ func main() {
 			ClientId         string `toml:"client_id"`
 			ClientSecretPath string `toml:"client_secret"`
 		} `toml:"authentication"`
-		RelaxTimeoutFactor uint   `toml:"RelaxTimeoutFactor"`
+		RelaxTimeoutFactor uint   `toml:"RelaxTimeoutFactor"` // Should be moved under 'koji' section
 		BasePath           string `toml:"base_path"`
 		DNFJson            string `toml:"dnf-json"`
 	}
@@ -420,7 +420,7 @@ func main() {
 	defer depsolveCtxCancel()
 	go func() {
 		jobImpls := map[string]JobImplementation{
-			"depsolve": &DepsolveJobImpl{
+			worker.JobTypeDepsolve: &DepsolveJobImpl{
 				Solver: solver,
 			},
 		}
@@ -448,14 +448,15 @@ func main() {
 
 	// non-depsolve job
 	jobImpls := map[string]JobImplementation{
-		"osbuild": &OSBuildJobImpl{
-			Store:       store,
-			Output:      output,
-			KojiServers: kojiServers,
-			GCPCreds:    gcpCredentials,
-			AzureCreds:  azureCredentials,
-			AWSCreds:    awsCredentials,
-			AWSBucket:   awsBucket,
+		worker.JobTypeOSBuild: &OSBuildJobImpl{
+			Store:                  store,
+			Output:                 output,
+			KojiServers:            kojiServers,
+			KojiRelaxTimeoutFactor: config.RelaxTimeoutFactor,
+			GCPCreds:               gcpCredentials,
+			AzureCreds:             azureCredentials,
+			AWSCreds:               awsCredentials,
+			AWSBucket:              awsBucket,
 			S3Config: S3Configuration{
 				Creds:               genericS3Credentials,
 				Endpoint:            genericS3Endpoint,
@@ -465,17 +466,17 @@ func main() {
 				SkipSSLVerification: genericS3SkipSSLVerification,
 			},
 		},
-		"osbuild-koji": &OSBuildKojiJobImpl{
+		worker.JobTypeOSBuildKoji: &OSBuildKojiJobImpl{
 			Store:              store,
 			Output:             output,
 			KojiServers:        kojiServers,
 			relaxTimeoutFactor: config.RelaxTimeoutFactor,
 		},
-		"koji-init": &KojiInitJobImpl{
+		worker.JobTypeKojiInit: &KojiInitJobImpl{
 			KojiServers:        kojiServers,
 			relaxTimeoutFactor: config.RelaxTimeoutFactor,
 		},
-		"koji-finalize": &KojiFinalizeJobImpl{
+		worker.JobTypeKojiFinalize: &KojiFinalizeJobImpl{
 			KojiServers:        kojiServers,
 			relaxTimeoutFactor: config.RelaxTimeoutFactor,
 		},
diff --git a/internal/boot/context-managers.go b/internal/boot/context-managers.go
index 88b2a1206..29444360a 100644
--- a/internal/boot/context-managers.go
+++ b/internal/boot/context-managers.go
@@ -115,7 +115,7 @@ func WithBootedQemuImage(image string, ns NetNS, f func() error) error {
 
 		var qemuCmd *exec.Cmd
 		if common.CurrentArch() == "x86_64" {
-			hostDistroName, _, _, err := distro.GetHostDistroName()
+			hostDistroName, _, _, err := common.GetHostDistroName()
 			if err != nil {
 				return fmt.Errorf("cannot determing the current distro: %v", err)
 			}
diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go
index a636c0f0c..0079fd543 100644
--- a/internal/cloudapi/v2/handler.go
+++ b/internal/cloudapi/v2/handler.go
@@ -14,8 +14,8 @@ import (
 	"github.com/google/uuid"
 	"github.com/labstack/echo/v4"
 
-	"github.com/osbuild/osbuild-composer/internal/auth"
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild2"
 	"github.com/osbuild/osbuild-composer/internal/ostree"
@@ -122,15 +122,9 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	}
 
 	// channel is empty if JWT is not enabled
-	var channel string
-	if h.server.config.JWTEnabled {
-		tenant, err := auth.GetFromClaims(ctx.Request().Context(), h.server.config.TenantProviderFields)
-		if err != nil {
-			return HTTPErrorWithInternal(ErrorTenantNotFound, err)
-		}
-
-		// prefix the tenant to prevent collisions if support for specifying channels in a request is ever added
-		channel = "org-" + tenant
+	channel, err := h.server.getTenantChannel(ctx)
+	if err != nil {
+		return HTTPErrorWithInternal(ErrorTenantNotFound, err)
 	}
 
 	distribution := h.server.distros.GetDistro(request.Distribution)
@@ -446,7 +440,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		}
 	}
 
-	ctx.Logger().Infof("Job ID %s enqueued for operationID %s", id, ctx.Get("operationID"))
+	ctx.Logger().Infof("Job ID %s enqueued for operationID %s", id, ctx.Get(common.OperationIDKey))
 
 	return ctx.JSON(http.StatusCreated, &ComposeId{
 		ObjectReference: ObjectReference{
@@ -498,6 +492,10 @@ func imageTypeFromApiImageType(it ImageTypes, arch distro.Arch) string {
 }
 
 func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
+	return h.server.EnsureJobChannel(h.getComposeStatusImpl)(ctx, id)
+}
+
+func (h *apiHandlers) getComposeStatusImpl(ctx echo.Context, id string) error {
 	jobId, err := uuid.Parse(id)
 	if err != nil {
 		return HTTPError(ErrorInvalidComposeId)
@@ -508,18 +506,16 @@ func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
 		return HTTPError(ErrorComposeNotFound)
 	}
 
-	if jobType == "osbuild" {
+	if jobType == worker.JobTypeOSBuild {
 		var result worker.OSBuildJobResult
-		status, deps, err := h.server.workers.OSBuildJobStatus(jobId, &result)
+		status, _, err := h.server.workers.OSBuildJobStatus(jobId, &result)
 		if err != nil {
 			return HTTPError(ErrorMalformedOSBuildJobResult)
 		}
 
-		if result.JobError != nil && result.JobError.HasDependencyError() {
-			err = h.server.workers.CheckBuildDependencies(deps[0], result.JobError)
-			if err != nil {
-				return HTTPError(ErrorGettingBuildDependencyStatus)
-			}
+		jobError, err := h.server.workers.JobDependencyChainErrors(jobId)
+		if err != nil {
+			return HTTPError(ErrorGettingBuildDependencyStatus)
 		}
 
 		var us *UploadStatus
@@ -580,11 +576,11 @@ func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
 			Status: composeStatusFromOSBuildJobStatus(status, &result),
 			ImageStatus: ImageStatus{
 				Status:       imageStatusFromOSBuildJobStatus(status, &result),
-				Error:        composeStatusErrorFromJobError(result.JobError),
+				Error:        composeStatusErrorFromJobError(jobError),
 				UploadStatus: us,
 			},
 		})
-	} else if jobType == "koji-finalize" {
+	} else if jobType == worker.JobTypeKojiFinalize {
 		var result worker.KojiFinalizeJobResult
 		finalizeStatus, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &result)
 		if err != nil {
@@ -598,24 +594,22 @@ func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
 		if err != nil {
 			return HTTPError(ErrorMalformedOSBuildJobResult)
 		}
-		var buildJobResults []worker.OSBuildKojiJobResult
+		var buildJobResults []worker.OSBuildJobResult
 		var buildJobStatuses []ImageStatus
 		for i := 1; i < len(deps); i++ {
-			var buildJobResult worker.OSBuildKojiJobResult
-			buildJobStatus, buildDeps, err := h.server.workers.OSBuildKojiJobStatus(deps[i], &buildJobResult)
+			var buildJobResult worker.OSBuildJobResult
+			buildJobStatus, _, err := h.server.workers.OSBuildJobStatus(deps[i], &buildJobResult)
 			if err != nil {
 				return HTTPError(ErrorMalformedOSBuildJobResult)
 			}
-			if buildJobResult.JobError != nil && buildJobResult.JobError.HasDependencyError() {
-				err = h.server.workers.CheckBuildDependencies(buildDeps[1], buildJobResult.JobError)
-				if err != nil {
-					return HTTPError(ErrorGettingBuildDependencyStatus)
-				}
+			buildJobError, err := h.server.workers.JobDependencyChainErrors(deps[i])
+			if err != nil {
+				return HTTPError(ErrorGettingBuildDependencyStatus)
 			}
 			buildJobResults = append(buildJobResults, buildJobResult)
 			buildJobStatuses = append(buildJobStatuses, ImageStatus{
 				Status: imageStatusFromKojiJobStatus(buildJobStatus, &initResult, &buildJobResult),
-				Error:  composeStatusErrorFromJobError(buildJobResult.JobError),
+				Error:  composeStatusErrorFromJobError(buildJobError),
 			})
 		}
 		response := ComposeStatus{
@@ -672,7 +666,7 @@ func imageStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBuil
 	return ImageStatusValueFailure
 }
 
-func imageStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResult *worker.OSBuildKojiJobResult) ImageStatusValue {
+func imageStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResult *worker.OSBuildJobResult) ImageStatusValue {
 	if js.Canceled {
 		return ImageStatusValueFailure
 	}
@@ -693,6 +687,10 @@ func imageStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiI
 		return ImageStatusValueFailure
 	}
 
+	if buildResult.OSBuildOutput != nil && !buildResult.OSBuildOutput.Success {
+		return ImageStatusValueFailure
+	}
+
 	return ImageStatusValueSuccess
 }
 
@@ -712,7 +710,7 @@ func composeStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBu
 	return ComposeStatusValueFailure
 }
 
-func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResults []worker.OSBuildKojiJobResult, result *worker.KojiFinalizeJobResult) ComposeStatusValue {
+func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResults []worker.OSBuildJobResult, result *worker.KojiFinalizeJobResult) ComposeStatusValue {
 	if js.Canceled {
 		return ComposeStatusValueFailure
 	}
@@ -729,6 +727,10 @@ func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.Koj
 		if buildResult.JobError != nil {
 			return ComposeStatusValueFailure
 		}
+
+		if buildResult.OSBuildOutput != nil && !buildResult.OSBuildOutput.Success {
+			return ComposeStatusValueFailure
+		}
 	}
 
 	if result.JobError != nil {
@@ -740,6 +742,10 @@ func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.Koj
 
 // ComposeMetadata handles a /composes/{id}/metadata GET request
 func (h *apiHandlers) GetComposeMetadata(ctx echo.Context, id string) error {
+	return h.server.EnsureJobChannel(h.getComposeMetadataImpl)(ctx, id)
+}
+
+func (h *apiHandlers) getComposeMetadataImpl(ctx echo.Context, id string) error {
 	jobId, err := uuid.Parse(id)
 	if err != nil {
 		return HTTPError(ErrorInvalidComposeId)
@@ -751,7 +757,7 @@ func (h *apiHandlers) GetComposeMetadata(ctx echo.Context, id string) error {
 	}
 
 	// TODO: support koji builds
-	if jobType != "osbuild" {
+	if jobType != worker.JobTypeOSBuild {
 		return HTTPError(ErrorInvalidJobType)
 	}
 
@@ -851,6 +857,11 @@ func stagesToPackageMetadata(stages []osbuild.RPMStageMetadata) []PackageMetadat
 
 // Get logs for a compose
 func (h *apiHandlers) GetComposeLogs(ctx echo.Context, id string) error {
+	return h.server.EnsureJobChannel(h.getComposeLogsImpl)(ctx, id)
+}
+
+func (h *apiHandlers) getComposeLogsImpl(ctx echo.Context, id string) error {
+
 	jobId, err := uuid.Parse(id)
 	if err != nil {
 		return HTTPError(ErrorInvalidComposeId)
@@ -861,54 +872,110 @@ func (h *apiHandlers) GetComposeLogs(ctx echo.Context, id string) error {
 		return HTTPError(ErrorComposeNotFound)
 	}
 
-	// TODO: support non-koji builds
-	if jobType != "koji-finalize" {
-		return HTTPError(ErrorInvalidJobType)
-	}
+	var buildResultBlobs []interface{}
 
-	var finalizeResult worker.KojiFinalizeJobResult
-	_, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &finalizeResult)
-	if err != nil {
-		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+	resp := &ComposeLogs{
+		ObjectReference: ObjectReference{
+			Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", jobId),
+			Id:   jobId.String(),
+			Kind: "ComposeLogs",
+		},
 	}
 
-	var initResult worker.KojiInitJobResult
-	_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
-	if err != nil {
-		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
-	}
+	switch jobType {
+	case worker.JobTypeKojiFinalize:
+		var finalizeResult worker.KojiFinalizeJobResult
+		_, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &finalizeResult)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
 
-	var buildResultBlobs []interface{}
-	for i := 1; i < len(deps); i++ {
-		var buildResult worker.OSBuildKojiJobResult
-		_, _, err = h.server.workers.OSBuildKojiJobStatus(deps[i], &buildResult)
+		var initResult worker.KojiInitJobResult
+		_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
+
+		for i := 1; i < len(deps); i++ {
+			buildJobType, err := h.server.workers.JobType(deps[i])
+			if err != nil {
+				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+			}
+
+			switch buildJobType {
+			// TODO: remove eventually. Kept for backward compatibility
+			case worker.JobTypeOSBuildKoji:
+				var buildResult worker.OSBuildKojiJobResult
+				_, _, err = h.server.workers.OSBuildKojiJobStatus(deps[i], &buildResult)
+				if err != nil {
+					return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+				}
+				buildResultBlobs = append(buildResultBlobs, buildResult)
+
+			case worker.JobTypeOSBuild:
+				var buildResult worker.OSBuildJobResult
+				_, _, err = h.server.workers.OSBuildJobStatus(deps[i], &buildResult)
+				if err != nil {
+					return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+				}
+				buildResultBlobs = append(buildResultBlobs, buildResult)
+
+			default:
+				return HTTPErrorWithInternal(ErrorInvalidJobType,
+					fmt.Errorf("unexpected job type in koji compose dependencies: %q", buildJobType))
+			}
+		}
+
+		resp.Koji = &KojiLogs{
+			Init:   initResult,
+			Import: finalizeResult,
+		}
+
+	case worker.JobTypeOSBuild:
+		var buildResult worker.OSBuildJobResult
+		_, _, err = h.server.workers.OSBuildJobStatus(jobId, &buildResult)
 		if err != nil {
 			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
 		}
 		buildResultBlobs = append(buildResultBlobs, buildResult)
+
+	default:
+		return HTTPError(ErrorInvalidJobType)
 	}
 
-	// Return the OSBuildJobResults as-is for now. The contents of ImageLogs
+	// Return the OSBuildJobResults as-is for now. The contents of ImageBuilds
 	// is not part of the API. It's meant for a human to be able to access
 	// the logs, which just happen to be in JSON.
-	resp := &ComposeLogs{
-		ObjectReference: ObjectReference{
-			Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", jobId),
-			Id:   jobId.String(),
-			Kind: "ComposeLogs",
-		},
-		Koji: &KojiLogs{
-			Init:   initResult,
-			Import: finalizeResult,
-		},
-		ImageBuilds: buildResultBlobs,
+	resp.ImageBuilds = buildResultBlobs
+	return ctx.JSON(http.StatusOK, resp)
+}
+
+func manifestJobResultsFromJobDeps(w *worker.Server, deps []uuid.UUID) (*worker.ManifestJobByIDResult, error) {
+	var manifestResult worker.ManifestJobByIDResult
+
+	for i := 0; i < len(deps); i++ {
+		depType, err := w.JobType(deps[i])
+		if err != nil {
+			return nil, err
+		}
+		if depType == worker.JobTypeManifestIDOnly {
+			_, _, err = w.ManifestJobStatus(deps[i], &manifestResult)
+			if err != nil {
+				return nil, err
+			}
+			return &manifestResult, nil
+		}
 	}
 
-	return ctx.JSON(http.StatusOK, resp)
+	return nil, fmt.Errorf("no %q job found in the dependencies", worker.JobTypeManifestIDOnly)
 }
 
 // GetComposeIdManifests returns the Manifests for a given Compose (one for each image).
 func (h *apiHandlers) GetComposeManifests(ctx echo.Context, id string) error {
+	return h.server.EnsureJobChannel(h.getComposeManifestsImpl)(ctx, id)
+}
+
+func (h *apiHandlers) getComposeManifestsImpl(ctx echo.Context, id string) error {
 	jobId, err := uuid.Parse(id)
 	if err != nil {
 		return HTTPError(ErrorInvalidComposeId)
@@ -919,43 +986,100 @@ func (h *apiHandlers) GetComposeManifests(ctx echo.Context, id string) error {
 		return HTTPError(ErrorComposeNotFound)
 	}
 
-	// TODO: support non-koji builds
-	if jobType != "koji-finalize" {
-		return HTTPError(ErrorInvalidJobType)
-	}
+	var manifestBlobs []interface{}
 
-	var finalizeResult worker.KojiFinalizeJobResult
-	_, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &finalizeResult)
-	if err != nil {
-		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
-	}
+	switch jobType {
+	case worker.JobTypeKojiFinalize:
+		var finalizeResult worker.KojiFinalizeJobResult
+		_, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &finalizeResult)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
 
-	var manifestBlobs []interface{}
-	for _, id := range deps[1:] {
-		var buildJob worker.OSBuildKojiJob
-		err = h.server.workers.OSBuildKojiJob(id, &buildJob)
+		for i := 1; i < len(deps); i++ {
+			buildJobType, err := h.server.workers.JobType(deps[i])
+			if err != nil {
+				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+			}
+
+			var manifest distro.Manifest
+
+			switch buildJobType {
+			// TODO: remove eventually. Kept for backward compatibility
+			case worker.JobTypeOSBuildKoji:
+				var buildJob worker.OSBuildKojiJob
+				err = h.server.workers.OSBuildKojiJob(deps[i], &buildJob)
+				if err != nil {
+					return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+				}
+
+				if len(buildJob.Manifest) != 0 {
+					manifest = buildJob.Manifest
+				} else {
+					_, buildDeps, err := h.server.workers.OSBuildKojiJobStatus(deps[i], &worker.OSBuildKojiJobResult{})
+					if err != nil {
+						return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+					}
+					manifestResult, err := manifestJobResultsFromJobDeps(h.server.workers, buildDeps)
+					if err != nil {
+						return HTTPErrorWithInternal(ErrorComposeNotFound, fmt.Errorf("job %q: %v", jobId, err))
+					}
+					manifest = manifestResult.Manifest
+				}
+
+			case worker.JobTypeOSBuild:
+				var buildJob worker.OSBuildJob
+				err = h.server.workers.OSBuildJob(deps[i], &buildJob)
+				if err != nil {
+					return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+				}
+
+				if len(buildJob.Manifest) != 0 {
+					manifest = buildJob.Manifest
+				} else {
+					_, buildDeps, err := h.server.workers.OSBuildJobStatus(deps[i], &worker.OSBuildJobResult{})
+					if err != nil {
+						return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+					}
+					manifestResult, err := manifestJobResultsFromJobDeps(h.server.workers, buildDeps)
+					if err != nil {
+						return HTTPErrorWithInternal(ErrorComposeNotFound, fmt.Errorf("job %q: %v", jobId, err))
+					}
+					manifest = manifestResult.Manifest
+				}
+
+			default:
+				return HTTPErrorWithInternal(ErrorInvalidJobType,
+					fmt.Errorf("unexpected job type in koji compose dependencies: %q", buildJobType))
+			}
+			manifestBlobs = append(manifestBlobs, manifest)
+		}
+
+	case worker.JobTypeOSBuild:
+		var buildJob worker.OSBuildJob
+		err = h.server.workers.OSBuildJob(jobId, &buildJob)
 		if err != nil {
 			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
 		}
+
 		var manifest distro.Manifest
-		if len(buildJob.Manifest) == 0 {
+		if len(buildJob.Manifest) != 0 {
 			manifest = buildJob.Manifest
 		} else {
-			_, deps, err := h.server.workers.OSBuildKojiJobStatus(id, nil)
+			_, deps, err := h.server.workers.OSBuildJobStatus(jobId, &worker.OSBuildJobResult{})
 			if err != nil {
 				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
 			}
-			if len(deps) < 2 {
-				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
-			}
-			var manifestResult worker.ManifestJobByIDResult
-			_, _, err = h.server.workers.ManifestJobStatus(deps[1], &manifestResult)
+			manifestResult, err := manifestJobResultsFromJobDeps(h.server.workers, deps)
 			if err != nil {
-				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+				return HTTPErrorWithInternal(ErrorComposeNotFound, fmt.Errorf("job %q: %v", jobId, err))
 			}
 			manifest = manifestResult.Manifest
 		}
 		manifestBlobs = append(manifestBlobs, manifest)
+
+	default:
+		return HTTPError(ErrorInvalidJobType)
 	}
 
 	resp := &ComposeManifests{
diff --git a/internal/cloudapi/v2/middleware.go b/internal/cloudapi/v2/middleware.go
new file mode 100644
index 000000000..0909c714f
--- /dev/null
+++ b/internal/cloudapi/v2/middleware.go
@@ -0,0 +1,82 @@
+package v2
+
+import (
+	"fmt"
+
+	"github.com/getkin/kin-openapi/openapi3filter"
+	"github.com/google/uuid"
+	"github.com/labstack/echo/v4"
+	"github.com/osbuild/osbuild-composer/internal/auth"
+)
+
+// getTenantChannel returns the tenant channel for the provided request context
+func (s *Server) getTenantChannel(ctx echo.Context) (string, error) {
+	// channel is empty if JWT is not enabled
+	var channel string
+	if s.config.JWTEnabled {
+		tenant, err := auth.GetFromClaims(ctx.Request().Context(), s.config.TenantProviderFields)
+		if err != nil {
+			return "", err
+		}
+		// prefix the tenant to prevent collisions if support for specifying channels in a request is ever added
+		channel = "org-" + tenant
+	}
+	return channel, nil
+}
+
+type ComposeHandlerFunc func(ctx echo.Context, id string) error
+
+// Ensures that the job's channel matches the JWT cannel set in the echo.Context
+func (s *Server) EnsureJobChannel(next ComposeHandlerFunc) ComposeHandlerFunc {
+	return func(c echo.Context, id string) error {
+		jobId, err := uuid.Parse(id)
+		if err != nil {
+			return HTTPError(ErrorInvalidComposeId)
+		}
+
+		ctxChannel, err := s.getTenantChannel(c)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorTenantNotFound, err)
+		}
+
+		jobChannel, err := s.workers.JobChannel(jobId)
+		if err != nil {
+			fmt.Println(err)
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
+
+		if jobChannel != ctxChannel {
+			return HTTPError(ErrorComposeNotFound)
+		}
+
+		return next(c, id)
+	}
+}
+
+func (s *Server) ValidateRequest(next echo.HandlerFunc) echo.HandlerFunc {
+	return func(c echo.Context) error {
+		request := c.Request()
+
+		// extract route and parameters from request
+		route, params, err := s.router.FindRoute(request)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorResourceNotFound, err)
+		}
+
+		input := &openapi3filter.RequestValidationInput{
+			Request:    request,
+			PathParams: params,
+			Route:      route,
+			Options: &openapi3filter.Options{
+				AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
+			},
+		}
+
+		ctx := request.Context()
+		if err := openapi3filter.ValidateRequest(ctx, input); err != nil {
+			return HTTPErrorWithInternal(ErrorInvalidRequest, err)
+		}
+
+		return next(c)
+	}
+}
diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go
index 200d1ae07..009661435 100644
--- a/internal/cloudapi/v2/openapi.v2.gen.go
+++ b/internal/cloudapi/v2/openapi.v2.gen.go
@@ -738,33 +738,33 @@ var swaggerSpec = []string{
 	"5yp585kzD5A44Rlim4gVDfJ1WkdEUK7w/z2MV/6spLhgCLobmKH8v1QInij65BH1pn8ELZs15NgKFiZW",
 	"FDGAoNCswvd1zAAgl2EFB1gVE9aJblW/HpE/POwhBxP0PbaWvZfqVG8TyQT9YqMAs7kbrMCEviMSZyZ0",
 	"OErurOgcCcRcTBAHcxuFh5mg0rDVhKvCoiCIVKtSaZS4cGlXwuKOCP2dUvlODK4LPAtKvmEQt32pAekM",
-	"iZR8tbGdHuR8TpkRt6cysnyJDVH3I9RYFSIcW/bOrQ3BfJSM0SfKLEjCXordzEdBy+cKh9Me+zRubkFa",
-	"bugGqZ8K807cv0VYcpfLWzRssGxj9XEHqr1KMCTLI8rvcRdt3pOfztm9tfHZlL1S9ac49i9PvP9I7tU+",
-	"MY/avRmCTtATQwm6McHYF2CfUKk9UOkTEoCaIxKz/jRQcF0ESVgfgo4DYgaCgPt8RCBDADqchvq6hxeu",
-	"xoYV6Bmm6oqBsjqK4BFhvoOCnh+GTMpQEswRsOFsVSVXOwpUgVeubowAnAf1RihUUzsn38SIeJRzPHbU",
-	"NBcvVK3XhUK3gUsZAiGHgaCWsjLSzK3k51BOaiNFrqj9klytGumOFqsjZ+wWZ74gVNGMH0dnzTfnrdLm",
-	"x5Q9golh3eNQQ2CYC4j4/GNnR76YPmc+IYdy5JvkxCXJ0zy/SmAHufBYKBzF9COqyt12dWZtQtXL2Ht5",
-	"e42au86GczuFjFyxmD0F1Wq1Ws9fv8F61nk+b2avB42ifNa8ZpftBusO8Um3ez/3r2Cv2nJ7Hdp865m5",
-	"1/OccV5802qDRaa0iCNiP7Xuc8Q+zzQfKLUpX6L7DItlXwpCwKAagixg3Fh9uoj8RutxEF2PVG4sGLeC",
-	"Kl1kcEkSE5Puh0P9sHIuaBjDqA6WoNQQFHa5DAkcrCMSJMLCe5lVD+o2AjlVBFBebxVxzufzNFSvVZgX",
-	"zuWZTrPeuO43Urm0lraF66gdxEKx7KZfU+jD8hQDqkUEQA9vZLjOErmwUY3IF2eJfFpLZ1WqWNiKTZmw",
-	"sUZJGOVxWV+GoEAAAoLmIBydBB4ViAisPIFOCQ9bm6gJOJohBiNeKPaEvT7qdmvQa4IZMJCcEvatbDa9",
-	"NY3EWeKWchEuLRFIAeKiRo1l0JGnUmrK93qeg4O+lMwkbLZbX309oq636vzeljYZ6gT3yTxKws7onJb9",
-	"1dibRoB4h+XBS2BDDriATCBDbmNB034Z/rCQuY+7SQLPGu50dGcxwJ/99+Ov+kIKyRSp+BsH1ATY8/9+",
-	"7PcE+sKmDL8FMb+HmAw4wUo4A0oK/wlKpoTOyWofAiYU/xMicE/QwkO6QAZQFXJAdd1nUi02ba0KTCIr",
-	"+9cPGTNy33UhW66NRmRc5LzI0vDMT2y8Kx8W1zB5iUTQjKa8sWqdBKGTBZQpiA6SpIXgVEOdkhTd8Y2N",
-	"Ix1lqr1Gwop4qFw5MpCxb28ukdi+npHc+v2Av+LvRq4AB8QKCizVoqnu5Usbu76WH/b8b9qXzUv6v/yq",
-	"3I8946X9auO1akvYk6BtvvzXbFdkOH6brd9m6yizNdgxPIftV8YJWxb+GSNmYoK5vWHDwIcmDIu15Uqq",
-	"gEqdgF0kIJBBqjQEmBIAx9QX0dV23xEfWTnVcfHbxn1q48J7t+/JmGZwKQKrhvrg5yBW8TEmgFCV/cS6",
-	"70AWdhCDP4RNfcsO0xet/s3193S8fRRoITKeA/EO0TE/53KcFSz8KgRxOv6+qUaXqlvcivLEkZTHqdHW",
-	"HeIPdWk18gh16iHhM8LVz2tE8xQx6ggStt+Szd/kSAPVIr4arFOlWDzqjQ+3z0AmJsgAUIDNwxvl6iwY",
-	"FAkgyYTfUxG4dPEDVVzfzf6tj5/q45pZB5Rya7v3FPP/T13bVo8jlG6jF+hjnQsHBiq3p2fBXRa0gLrY",
-	"ckRMqR8ygIE8RAyph5u6Fv24TnDD4iPNiOj8rRifK8bq+v8BvYi28it68TtG/x2j/78Wo+/Zpjh7p4Bv",
-	"xhR7JmZ9gXbPuMStbD0ko9psDxVANsapPtx/q+qv1xAn7cGPilAThMz4rWb/HTULBP1/T8ngSoCg44BV",
-	"rTOSprWafZ7QgyQokRB99etrAWXru77jJVCuM15Rj4sAVnD/Va+f/w/78INbqV6AzWe/tfi3Fn9Fi9G+",
-	"BEnNXZUED3vIm3BIvNxvExuCU/osT9aSB+GZ+X8xtvhwOe+r5qU4S9QNLx5Tw9eD2/KrC1LbRV/o4bTE",
-	"w20c/vIh9HAmuLqmsgeIpaJfPcjMciri2ClFC2hhYn2EgAtooX8RjWIiiS5Gr9B8BufH+/8NAAD///0e",
-	"w1eRWQAA",
+	"iZR8tbGdHuR8TpkRt6cysnyJDVH3I9QjRAQTji175xKHYD5KxqgXZRYkYWvFbiKkoOVzhcNZkH2SN3ck",
+	"Lfd3g/JPCd85BmwRltxl+hYNGxzcWH3c+WqvMAzJ8ohqfNy9m/fkp3N2L3F8NmWvcv0pjv27FO8/knul",
+	"UMyj7m+GoBO0yFCCbkww9gXYJ1QqE1TqhQSg5ojErD8NFFwXQRKWi6DjgJiBIOA+HxHIEIAOp6H67uGF",
+	"q7FhQXqGqbpxoIyQInhEmO+goAWIIZMylARzBGw4WxXN1Y4CVe+VqxsjAOdB+REK1ePOyTcxIh7lHI8d",
+	"Nc3FC1X6daHQbeBShkDIYSCopYyOtHor+TmUotrImCtqvyRXq766o8XqyBm7tZovCFU048fRSfTNeass",
+	"+jFVkGBiWAY51B8YpgYiPv/Y2ZEvZtOZT8ihlPkmOXE58zTPr/LZQWo8FgpHMe2JqpC3XaxZm1D1Mvaa",
+	"3l7f5q7v4dxOISNXLGZPQbVardbz12+wnnWez5vZ60GjKJ81r9llu8G6Q3zS7d7P/SvYq7bcXoc233pm",
+	"7vU8Z5wX37TaYJEpLeKI2M+0+xyxzxPPBypvypfoPsNi2ZeCEDCohiALGDdWny4iv9F6HES3JZUbC8at",
+	"oEqPGdyZxMSk+9FRPyykCxqGNKqhJag8BHVeLiMEB+uIBHmx8Jpm1YO6jUBO1QSU11sFoPP5PA3VaxX1",
+	"hXN5ptOsN677jVQuraVt4TpqB7FQLLvp1xT6sFrFgOoYAdDDGwmvs0Qu7Fsj8sVZIp/W0lmVORa2YlMm",
+	"7LNREkZ5XBKYISgQgICgOQhHJ4FHBSICK0+gU8LDTidqAo5miMGIF4o9YeuPuuwatJ5gBgwkp4RtLJs9",
+	"cE0jcZa4pVyES0sEUoC4qFFjGTToqQyb8r2e5+CgTSUzCXvv1jdhjyjzrRrBt6VNhjrB9TKPkrBROqdl",
+	"fzX2phEg3mF58BLYkAMuIBPIkNtY0LRfhj+sa+7jbpLAs4Y7HV1hDPBn//34q76QQjJFKhzHATUB9vy/",
+	"H/s9gb6wKcNvwRHAQ0wGnGAlnAElhf8EJVNC52S1DwETiv8JEbgnaOEhXSADqII5oLruM6kWm7ZWBSaR",
+	"lf3rh4wZue+6kC3XRiMyLnJeZGl45ic23pUPi+ufvEQi6E1T3lh1UoLQyQLKFEQHSdJCcKq/TkmK7vjG",
+	"xgmPMtVtI2FFPFSuHBnI2Lc3l0hs39ZIbv2cwF/xVyVXgANiBQWW6thU1/SljV3f0g+vAGzal807+7/8",
+	"5tyPPeOl/WrjtepS2JOgbb7812xXZDh+m63fZusoszXYMTyH7VfGCTsY/hkjZmKCub1hw8CHJgyLteVK",
+	"qoBKnYBdJCCQQao0BJgSAMfUF9FNd98RH1k51YDx28Z9auPCa7jvyZjecCkCq/764NchVvExJoBQlQzF",
+	"uu9AFjYUgz+ETX3LDtMXrf7N9fd0vH0UaCEyngPxDtExv+5ynBUs/CoEcTr+vqlGl6p53IrSxpGUx6nR",
+	"1pXiD3VpNfIIdeoh4TPC1a9tRPMUMeoIEnbjks2f6EgD1TG+GqxTpVg8apUPt89AJibIAFCAzcMb5eos",
+	"GNQMIMmE31MRuHTxA1VcX9X+rY+f6uOaWQeUcmu79xTz/09d21aPI5RuozXoY50LBwYqt6dnwdUWtIC6",
+	"2HJETKkfMoCBPEQMqYebuhb91k5w4eIjzYjo/K0YnyvG6tcADuhFtJVf0YvfMfrvGP3/tRh9zzbF2TsF",
+	"fDOm2DMx6/u0e8YlbmXrIRnVdXuoALIxTrXl/ltVf72GOGkPfmOEmiBkxm81+++oWSDo/3tKBlcCBB0H",
+	"rGqdkTSt1ezzhB4kQYmE6KsfYwsoW1/9HS+Bcp3xinpcBLCC+696/fx/2Icf3Er1Amw++63Fv7X4K1qM",
+	"9iVIau6qJHjYQ96EQ+LlfpvYEJzSZ3myljwIz8z/i7HFh8t5XzUvxVmibngPmRq+HlyeX92X2i76Qg+n",
+	"JR5u4/CHEKGHM8FNNpU9QCwV/QhCZpZTEcdOKVpACxPrIwRcQAv9i2gUE0l0T3qF5jM4P97/bwAAAP//",
+	"1dx5t6BZAAA=",
 }
 
 // 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 b488e1f84..377fd18fa 100644
--- a/internal/cloudapi/v2/openapi.v2.yml
+++ b/internal/cloudapi/v2/openapi.v2.yml
@@ -899,6 +899,7 @@ components:
           example: 'subscription.rhsm.redhat.com'
         base_url:
           type: string
+          format: uri
           example: 'http://cdn.redhat.com/'
         insights:
           type: boolean
diff --git a/internal/cloudapi/v2/server.go b/internal/cloudapi/v2/server.go
index 657b7ce50..672a5b876 100644
--- a/internal/cloudapi/v2/server.go
+++ b/internal/cloudapi/v2/server.go
@@ -9,7 +9,6 @@ import (
 	"time"
 
 	"github.com/getkin/kin-openapi/openapi3"
-	"github.com/getkin/kin-openapi/openapi3filter"
 	"github.com/getkin/kin-openapi/routers"
 	legacyrouter "github.com/getkin/kin-openapi/routers/legacy"
 	"github.com/google/uuid"
@@ -91,34 +90,6 @@ func (s *Server) Handler(path string) http.Handler {
 	return e
 }
 
-func (s *Server) ValidateRequest(next echo.HandlerFunc) echo.HandlerFunc {
-	return func(c echo.Context) error {
-		request := c.Request()
-
-		// extract route and parameters from request
-		route, params, err := s.router.FindRoute(request)
-		if err != nil {
-			return HTTPErrorWithInternal(ErrorResourceNotFound, err)
-		}
-
-		input := &openapi3filter.RequestValidationInput{
-			Request:    request,
-			PathParams: params,
-			Route:      route,
-			Options: &openapi3filter.Options{
-				AuthenticationFunc: openapi3filter.NoopAuthenticationFunc,
-			},
-		}
-
-		ctx := request.Context()
-		if err := openapi3filter.ValidateRequest(ctx, input); err != nil {
-			return HTTPErrorWithInternal(ErrorInvalidRequest, err)
-		}
-
-		return next(c)
-	}
-}
-
 func (s *Server) Shutdown() {
 	s.goroutinesCtxCancel()
 	s.goroutinesGroup.Wait()
@@ -153,7 +124,7 @@ func (s *Server) enqueueCompose(distribution distro.Distro, bp blueprint.Bluepri
 			Build:   ir.imageType.BuildPipelines(),
 			Payload: ir.imageType.PayloadPipelines(),
 		},
-	}, manifestJobID, channel)
+	}, []uuid.UUID{manifestJobID}, channel)
 	if err != nil {
 		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
@@ -206,17 +177,20 @@ func (s *Server) enqueueKojiCompose(taskID uint64, server, name, version, releas
 			ir.arch.Name(),
 			splitExtension(ir.imageType.Filename()),
 		)
-		buildID, err := s.workers.EnqueueOSBuildKojiAsDependency(ir.arch.Name(), &worker.OSBuildKojiJob{
+		buildID, err := s.workers.EnqueueOSBuildAsDependency(ir.arch.Name(), &worker.OSBuildJob{
 			ImageName: ir.imageType.Filename(),
 			Exports:   ir.imageType.Exports(),
 			PipelineNames: &worker.PipelineNames{
 				Build:   ir.imageType.BuildPipelines(),
 				Payload: ir.imageType.PayloadPipelines(),
 			},
-			KojiServer:    server,
-			KojiDirectory: kojiDirectory,
-			KojiFilename:  kojiFilename,
-		}, manifestJobID, initID, channel)
+			Targets: []*target.Target{target.NewKojiTarget(&target.KojiTargetOptions{
+				Server:          server,
+				UploadDirectory: kojiDirectory,
+				Filename:        kojiFilename,
+			})},
+			ManifestDynArgsIdx: common.IntToPtr(1),
+		}, []uuid.UUID{initID, manifestJobID}, channel)
 		if err != nil {
 			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
diff --git a/internal/common/distro.go b/internal/common/distro.go
new file mode 100644
index 000000000..bd3c66bfb
--- /dev/null
+++ b/internal/common/distro.go
@@ -0,0 +1,79 @@
+package common
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"strings"
+)
+
+func GetHostDistroName() (string, bool, bool, error) {
+	f, err := os.Open("/etc/os-release")
+	if err != nil {
+		return "", false, false, err
+	}
+	defer f.Close()
+	osrelease, err := readOSRelease(f)
+	if err != nil {
+		return "", false, false, err
+	}
+
+	isStream := osrelease["NAME"] == "CentOS Stream"
+
+	// NOTE: We only consider major releases up until rhel 8.4
+	version := strings.Split(osrelease["VERSION_ID"], ".")
+	name := osrelease["ID"] + "-" + version[0]
+	if osrelease["ID"] == "rhel" && ((version[0] == "8" && version[1] >= "4") || version[0] == "9") {
+		name = name + version[1]
+	}
+
+	// TODO: We should probably index these things by the full CPE
+	beta := strings.Contains(osrelease["CPE_NAME"], "beta")
+	return name, beta, isStream, nil
+}
+
+// GetRedHatRelease returns the content of /etc/redhat-release
+// without the trailing new-line.
+func GetRedHatRelease() (string, error) {
+	raw, err := ioutil.ReadFile("/etc/redhat-release")
+	if err != nil {
+		return "", fmt.Errorf("cannot read /etc/redhat-release: %v", err)
+	}
+
+	//Remove the trailing new-line.
+	redHatRelease := strings.TrimSpace(string(raw))
+
+	return redHatRelease, nil
+}
+
+func readOSRelease(r io.Reader) (map[string]string, error) {
+	osrelease := make(map[string]string)
+	scanner := bufio.NewScanner(r)
+	for scanner.Scan() {
+		line := strings.TrimSpace(scanner.Text())
+		if len(line) == 0 {
+			continue
+		}
+
+		parts := strings.SplitN(line, "=", 2)
+		if len(parts) != 2 {
+			return nil, errors.New("readOSRelease: invalid input")
+		}
+
+		key := strings.TrimSpace(parts[0])
+		value := strings.TrimSpace(parts[1])
+		if value[0] == '"' {
+			if len(value) < 2 || value[len(value)-1] != '"' {
+				return nil, errors.New("readOSRelease: invalid input")
+			}
+			value = value[1 : len(value)-1]
+		}
+
+		osrelease[key] = value
+	}
+
+	return osrelease, nil
+}
diff --git a/internal/distro/distro.go b/internal/distro/distro.go
index 207b7dd83..f931f60b6 100644
--- a/internal/distro/distro.go
+++ b/internal/distro/distro.go
@@ -1,14 +1,8 @@
 package distro
 
 import (
-	"bufio"
 	"encoding/json"
-	"errors"
 	"fmt"
-	"io"
-	"io/ioutil"
-	"os"
-	"strings"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/disk"
@@ -186,74 +180,6 @@ func (m Manifest) Version() (string, error) {
 	}
 }
 
-func GetHostDistroName() (string, bool, bool, error) {
-	f, err := os.Open("/etc/os-release")
-	if err != nil {
-		return "", false, false, err
-	}
-	defer f.Close()
-	osrelease, err := readOSRelease(f)
-	if err != nil {
-		return "", false, false, err
-	}
-
-	isStream := osrelease["NAME"] == "CentOS Stream"
-
-	// NOTE: We only consider major releases up until rhel 8.4
-	version := strings.Split(osrelease["VERSION_ID"], ".")
-	name := osrelease["ID"] + "-" + version[0]
-	if osrelease["ID"] == "rhel" && ((version[0] == "8" && version[1] >= "4") || version[0] == "9") {
-		name = name + version[1]
-	}
-
-	// TODO: We should probably index these things by the full CPE
-	beta := strings.Contains(osrelease["CPE_NAME"], "beta")
-	return name, beta, isStream, nil
-}
-
-// GetRedHatRelease returns the content of /etc/redhat-release
-// without the trailing new-line.
-func GetRedHatRelease() (string, error) {
-	raw, err := ioutil.ReadFile("/etc/redhat-release")
-	if err != nil {
-		return "", fmt.Errorf("cannot read /etc/redhat-release: %v", err)
-	}
-
-	//Remove the trailing new-line.
-	redHatRelease := strings.TrimSpace(string(raw))
-
-	return redHatRelease, nil
-}
-
-func readOSRelease(r io.Reader) (map[string]string, error) {
-	osrelease := make(map[string]string)
-	scanner := bufio.NewScanner(r)
-	for scanner.Scan() {
-		line := strings.TrimSpace(scanner.Text())
-		if len(line) == 0 {
-			continue
-		}
-
-		parts := strings.SplitN(line, "=", 2)
-		if len(parts) != 2 {
-			return nil, errors.New("readOSRelease: invalid input")
-		}
-
-		key := strings.TrimSpace(parts[0])
-		value := strings.TrimSpace(parts[1])
-		if value[0] == '"' {
-			if len(value) < 2 || value[len(value)-1] != '"' {
-				return nil, errors.New("readOSRelease: invalid input")
-			}
-			value = value[1 : len(value)-1]
-		}
-
-		osrelease[key] = value
-	}
-
-	return osrelease, nil
-}
-
 // Fallbacks: When a new method is added to an interface to provide to provide
 // information that isn't available for older implementations, the older
 // methods should return a fallback/default value by calling the appropriate
diff --git a/internal/distro/rhel86/distro.go b/internal/distro/rhel86/distro.go
index 89c9dd62b..5bffebc0f 100644
--- a/internal/distro/rhel86/distro.go
+++ b/internal/distro/rhel86/distro.go
@@ -938,10 +938,15 @@ func newDistro(distroName string) distro.Distro {
 			},
 			Modprobe: []*osbuild.ModprobeStageOptions{
 				{
-					Filename: "blacklist-nouveau.conf",
+					Filename: "blacklist-amdgpu.conf",
 					Commands: osbuild.ModprobeConfigCmdList{
-						osbuild.NewModprobeConfigCmdBlacklist("nouveau"),
-						osbuild.NewModprobeConfigCmdBlacklist("lbm-nouveau"),
+						osbuild.NewModprobeConfigCmdBlacklist("amdgpu"),
+					},
+				},
+				{
+					Filename: "blacklist-intel-cstate.conf",
+					Commands: osbuild.ModprobeConfigCmdList{
+						osbuild.NewModprobeConfigCmdBlacklist("intel_cstate"),
 					},
 				},
 				{
@@ -951,15 +956,16 @@ func newDistro(distroName string) distro.Distro {
 					},
 				},
 				{
-					Filename: "blacklist-skylake-edac.conf",
+					Filename: "blacklist-nouveau.conf",
 					Commands: osbuild.ModprobeConfigCmdList{
-						osbuild.NewModprobeConfigCmdBlacklist("skx_edac"),
+						osbuild.NewModprobeConfigCmdBlacklist("nouveau"),
+						osbuild.NewModprobeConfigCmdBlacklist("lbm-nouveau"),
 					},
 				},
 				{
-					Filename: "blacklist-intel-cstate.conf",
+					Filename: "blacklist-skylake-edac.conf",
 					Commands: osbuild.ModprobeConfigCmdList{
-						osbuild.NewModprobeConfigCmdBlacklist("intel_cstate"),
+						osbuild.NewModprobeConfigCmdBlacklist("skx_edac"),
 					},
 				},
 			},
diff --git a/internal/distro/rhel90/distro.go b/internal/distro/rhel90/distro.go
index ddc435eaa..8248ff3fb 100644
--- a/internal/distro/rhel90/distro.go
+++ b/internal/distro/rhel90/distro.go
@@ -938,10 +938,9 @@ func newDistro(distroName string) distro.Distro {
 			},
 			Modprobe: []*osbuild.ModprobeStageOptions{
 				{
-					Filename: "blacklist-nouveau.conf",
+					Filename: "blacklist-amdgpu.conf",
 					Commands: osbuild.ModprobeConfigCmdList{
-						osbuild.NewModprobeConfigCmdBlacklist("nouveau"),
-						osbuild.NewModprobeConfigCmdBlacklist("lbm-nouveau"),
+						osbuild.NewModprobeConfigCmdBlacklist("amdgpu"),
 					},
 				},
 				{
@@ -950,6 +949,13 @@ func newDistro(distroName string) distro.Distro {
 						osbuild.NewModprobeConfigCmdBlacklist("floppy"),
 					},
 				},
+				{
+					Filename: "blacklist-nouveau.conf",
+					Commands: osbuild.ModprobeConfigCmdList{
+						osbuild.NewModprobeConfigCmdBlacklist("nouveau"),
+						osbuild.NewModprobeConfigCmdBlacklist("lbm-nouveau"),
+					},
+				},
 			},
 			CloudInit: []*osbuild.CloudInitStageOptions{
 				{
diff --git a/internal/distroregistry/distroregistry.go b/internal/distroregistry/distroregistry.go
index 6ddabc3e1..2b5be06ee 100644
--- a/internal/distroregistry/distroregistry.go
+++ b/internal/distroregistry/distroregistry.go
@@ -5,6 +5,7 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distro/fedora"
 	"github.com/osbuild/osbuild-composer/internal/distro/rhel8"
@@ -69,7 +70,7 @@ func NewDefault() *Registry {
 	// If there was an error, then the hostDistroName will be an empty string
 	// and as a result, the hostDistro will have a nil value when calling New().
 	// Getting the host distro later using FromHost() will return nil as well.
-	hostDistroName, hostDistroIsBeta, hostDistroIsStream, _ := distro.GetHostDistroName()
+	hostDistroName, hostDistroIsBeta, hostDistroIsStream, _ := common.GetHostDistroName()
 
 	for _, supportedDistro := range supportedDistros {
 		distro := supportedDistro.defaultDistro()
diff --git a/internal/dnfjson/cache.go b/internal/dnfjson/cache.go
index 926fdca1d..225e45c9d 100644
--- a/internal/dnfjson/cache.go
+++ b/internal/dnfjson/cache.go
@@ -1,11 +1,200 @@
 package dnfjson
 
 import (
+	"fmt"
 	"io/fs"
 	"os"
 	"path/filepath"
+	"sort"
+	"sync"
+	"time"
+
+	"github.com/gobwas/glob"
 )
 
+// global cache locker
+var cacheLocks sync.Map
+
+// A collection of directory paths, their total size, and their most recent
+// modification time.
+type pathInfo struct {
+	paths []string
+	size  uint64
+	mtime time.Time
+}
+
+type rpmCache struct {
+	// root path for the cache
+	root string
+
+	// individual repository cache data
+	repoElements map[string]pathInfo
+
+	// list of known repository IDs, sorted by mtime
+	repoRecency []string
+
+	// total cache size
+	size uint64
+
+	// max cache size
+	maxSize uint64
+
+	// locker for this cache directory
+	locker *sync.RWMutex
+}
+
+func newRPMCache(path string, maxSize uint64) *rpmCache {
+	absPath, err := filepath.Abs(path) // convert to abs if it's not already
+	if err != nil {
+		panic(err) // can only happen if the CWD does not exist and the path isn't already absolute
+	}
+	path = absPath
+	locker := new(sync.RWMutex)
+	if l, loaded := cacheLocks.LoadOrStore(path, locker); loaded {
+		// value existed and was loaded
+		locker = l.(*sync.RWMutex)
+	}
+	r := &rpmCache{
+		root:         path,
+		repoElements: make(map[string]pathInfo),
+		size:         0,
+		maxSize:      maxSize,
+		locker:       locker,
+	}
+	// collect existing cache paths and timestamps
+	r.updateInfo()
+	return r
+}
+
+// updateInfo updates the repoPaths and repoRecency fields of the rpmCache.
+func (r *rpmCache) updateInfo() {
+	cacheEntries, _ := os.ReadDir(r.root)
+
+	// each repository has multiple cache entries (3 on average), so using the
+	// number of cacheEntries to allocate the map and ID slice is a high upper
+	// bound, but guarantees we wont need to grow and reallocate either.
+	repos := make(map[string]pathInfo, len(cacheEntries))
+	repoIDs := make([]string, 0, len(cacheEntries))
+
+	var totalSize uint64
+
+	// Collect the paths grouped by their repo ID
+	// We assume the first 64 characters of a file or directory name are the
+	// repository ID because we use a sha256 sum of the repository config to
+	// create the ID (64 hex chars)
+	for _, entry := range cacheEntries {
+		eInfo, err := entry.Info()
+		if err != nil {
+			// skip it
+			continue
+		}
+
+		fname := entry.Name()
+		if len(fname) < 64 {
+			// unknown file in cache; ignore
+			continue
+		}
+		repoID := fname[:64]
+		repo, ok := repos[repoID]
+		if !ok {
+			// new repo ID
+			repoIDs = append(repoIDs, repoID)
+		}
+		mtime := eInfo.ModTime()
+		ePath := filepath.Join(r.root, entry.Name())
+
+		// calculate and add entry size
+		size, err := dirSize(ePath)
+		if err != nil {
+			// skip it
+			continue
+		}
+		repo.size += size
+		totalSize += size
+
+		// add path
+		repo.paths = append(repo.paths, ePath)
+
+		// if for some reason the mtimes of the various entries of a single
+		// repository are out of sync, use the most recent one
+		if repo.mtime.Before(mtime) {
+			repo.mtime = mtime
+		}
+
+		// update the collection
+		repos[repoID] = repo
+	}
+	sortFunc := func(idx, jdx int) bool {
+		ir := repos[repoIDs[idx]]
+		jr := repos[repoIDs[jdx]]
+		return ir.mtime.Before(jr.mtime)
+	}
+
+	// sort IDs by mtime (oldest first)
+	sort.Slice(repoIDs, sortFunc)
+
+	r.size = totalSize
+	r.repoElements = repos
+	r.repoRecency = repoIDs
+}
+
+func (r *rpmCache) shrink() error {
+	r.locker.Lock()
+	defer r.locker.Unlock()
+
+	// start deleting until we drop below r.maxSize
+	nDeleted := 0
+	for idx := 0; idx < len(r.repoRecency) && r.size >= r.maxSize; idx++ {
+		repoID := r.repoRecency[idx]
+		nDeleted++
+		repo, ok := r.repoElements[repoID]
+		if !ok {
+			// cache inconsistency?
+			// ignore and let the ID be removed from the recency list
+			continue
+		}
+		for _, gPath := range repo.paths {
+			if err := os.RemoveAll(gPath); err != nil {
+				return err
+			}
+		}
+		r.size -= repo.size
+		delete(r.repoElements, repoID)
+	}
+
+	// update recency list
+	r.repoRecency = r.repoRecency[nDeleted:]
+	return nil
+}
+
+// Update file atime and mtime on the filesystem to time t for all files in the
+// root of the cache that match the repo ID.  This should be called whenever a
+// repository is used.
+// This function does not update the internal cache info.  A call to
+// updateInfo() should be made after touching one or more repositories.
+func (r *rpmCache) touchRepo(repoID string, t time.Time) error {
+	repoGlob, err := glob.Compile(fmt.Sprintf("%s*", repoID))
+	if err != nil {
+		return err
+	}
+
+	// we only touch the top-level directories and files of the cache
+	cacheEntries, err := os.ReadDir(r.root)
+	if err != nil {
+		return err
+	}
+
+	for _, cacheEntry := range cacheEntries {
+		if repoGlob.Match(cacheEntry.Name()) {
+			path := filepath.Join(r.root, cacheEntry.Name())
+			if err := os.Chtimes(path, t, t); err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
 func dirSize(path string) (uint64, error) {
 	var size uint64
 	sizer := func(path string, info fs.FileInfo, err error) error {
@@ -18,14 +207,3 @@ func dirSize(path string) (uint64, error) {
 	err := filepath.Walk(path, sizer)
 	return size, err
 }
-
-func (bs *BaseSolver) CleanCache() error {
-	curSize, err := dirSize(bs.cacheDir)
-	if err != nil {
-		return err
-	}
-	if curSize > bs.maxCacheSize {
-		return os.RemoveAll(bs.cacheDir)
-	}
-	return nil
-}
diff --git a/internal/dnfjson/dnfjson.go b/internal/dnfjson/dnfjson.go
index 223b55c7a..5d29a4319 100644
--- a/internal/dnfjson/dnfjson.go
+++ b/internal/dnfjson/dnfjson.go
@@ -21,21 +21,24 @@ import (
 	"os"
 	"os/exec"
 	"sort"
+	"time"
 
 	"github.com/osbuild/osbuild-composer/internal/rhsm"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 )
 
+// BaseSolver defines the basic solver configuration without platform
+// information. It can be used to create configured Solver instances with the
+// NewWithConfig() method. A BaseSolver maintains the global repository cache
+// directory and system subscription information.
 type BaseSolver struct {
 	subscriptions *rhsm.Subscriptions
 
-	// Cache directory for the DNF metadata
-	cacheDir string
+	// Cache information
+	cache *rpmCache
 
 	// Path to the dnf-json binary and optional args (default: "/usr/libexec/osbuild-composer/dnf-json")
 	dnfJsonCmd []string
-
-	maxCacheSize uint64
 }
 
 // Create a new unconfigured BaseSolver (without platform information). It can
@@ -44,15 +47,17 @@ type BaseSolver struct {
 func NewBaseSolver(cacheDir string) *BaseSolver {
 	subscriptions, _ := rhsm.LoadSystemSubscriptions()
 	return &BaseSolver{
-		cacheDir:      cacheDir,
+		cache:         newRPMCache(cacheDir, 524288000), // 500 MiB
 		subscriptions: subscriptions,
 		dnfJsonCmd:    []string{"/usr/libexec/osbuild-composer/dnf-json"},
-		maxCacheSize:  524288000, // 500 MiB
 	}
 }
 
+// SetMaxCacheSize sets the maximum size for the global repository metadata
+// cache. This is the maximum size of the cache after a CleanCache()
+// call. Cache cleanup is never performed automatically.
 func (s *BaseSolver) SetMaxCacheSize(size uint64) {
-	s.maxCacheSize = size
+	s.cache.maxSize = size
 }
 
 // SetDNFJSONPath sets the path to the dnf-json binary and optionally any command line arguments.
@@ -71,6 +76,13 @@ func (bs *BaseSolver) NewWithConfig(modulePlatformID string, releaseVer string,
 	return s
 }
 
+// CleanCache deletes the least recently used repository metadata caches until
+// the total size of the cache falls below the configured maximum size (see
+// SetMaxCacheSize()).
+func (bs *BaseSolver) CleanCache() error {
+	return bs.cache.shrink()
+}
+
 // Solver is configured with system information in order to resolve
 // dependencies for RPM packages using DNF.
 type Solver struct {
@@ -103,10 +115,22 @@ func (s *Solver) Depsolve(pkgSets []rpmmd.PackageSet) ([]rpmmd.PackageSpec, erro
 		return nil, err
 	}
 
+	// get non-exclusive read lock
+	s.cache.locker.RLock()
+	defer s.cache.locker.RUnlock()
+
 	output, err := run(s.dnfJsonCmd, req)
 	if err != nil {
 		return nil, err
 	}
+	// touch repos to now
+	now := time.Now().Local()
+	for _, r := range repoMap {
+		// ignore errors
+		_ = s.cache.touchRepo(r.Hash(), now)
+	}
+	s.cache.updateInfo()
+
 	var result packageSpecs
 	if err := json.Unmarshal(output, &result); err != nil {
 		return nil, err
@@ -115,16 +139,31 @@ func (s *Solver) Depsolve(pkgSets []rpmmd.PackageSet) ([]rpmmd.PackageSpec, erro
 	return result.toRPMMD(repoMap), nil
 }
 
+// FetchMetadata returns the list of all the available packages in repos and
+// their info.
 func (s *Solver) FetchMetadata(repos []rpmmd.RepoConfig) (rpmmd.PackageList, error) {
 	req, err := s.makeDumpRequest(repos)
 	if err != nil {
 		return nil, err
 	}
+
+	// get non-exclusive read lock
+	s.cache.locker.RLock()
+	defer s.cache.locker.RUnlock()
+
 	result, err := run(s.dnfJsonCmd, req)
 	if err != nil {
 		return nil, err
 	}
 
+	// touch repos to now
+	now := time.Now().Local()
+	for _, r := range repos {
+		// ignore errors
+		_ = s.cache.touchRepo(r.Hash(), now)
+	}
+	s.cache.updateInfo()
+
 	var pkgs rpmmd.PackageList
 	if err := json.Unmarshal(result, &pkgs); err != nil {
 		return nil, err
@@ -254,7 +293,7 @@ func (s *Solver) makeDepsolveRequest(pkgSets []rpmmd.PackageSet) (*Request, map[
 		Command:          "depsolve",
 		ModulePlatformID: s.modulePlatformID,
 		Arch:             s.arch,
-		CacheDir:         s.cacheDir,
+		CacheDir:         s.cache.root,
 		Arguments:        args,
 	}
 
@@ -271,7 +310,7 @@ func (s *Solver) makeDumpRequest(repos []rpmmd.RepoConfig) (*Request, error) {
 		Command:          "dump",
 		ModulePlatformID: s.modulePlatformID,
 		Arch:             s.arch,
-		CacheDir:         s.cacheDir,
+		CacheDir:         s.cache.root,
 		Arguments: arguments{
 			Repos: dnfRepos,
 		},
diff --git a/internal/target/koji_target.go b/internal/target/koji_target.go
index de93f0d66..a46c744e2 100644
--- a/internal/target/koji_target.go
+++ b/internal/target/koji_target.go
@@ -1,16 +1,9 @@
 package target
 
 type KojiTargetOptions struct {
-	BuildID         uint64 `json:"build_id"`
-	TaskID          uint64 `json:"task_id"`
-	Token           string `json:"token"`
-	Name            string `json:"name"`
-	Version         string `json:"version"`
-	Release         string `json:"release"`
 	Filename        string `json:"filename"`
 	UploadDirectory string `json:"upload_directory"`
 	Server          string `json:"server"`
-	KojiFilename    string `json:"koji_filename"`
 }
 
 func (KojiTargetOptions) isTargetOptions() {}
@@ -18,3 +11,14 @@ func (KojiTargetOptions) isTargetOptions() {}
 func NewKojiTarget(options *KojiTargetOptions) *Target {
 	return newTarget("org.osbuild.koji", options)
 }
+
+type KojiTargetResultOptions struct {
+	ImageMD5  string `json:"image_md5"`
+	ImageSize uint64 `json:"image_size"`
+}
+
+func (KojiTargetResultOptions) isTargetResultOptions() {}
+
+func NewKojiTargetResult(options *KojiTargetResultOptions) *TargetResult {
+	return newTargetResult("org.osbuild.koji", options)
+}
diff --git a/internal/target/targetresult.go b/internal/target/targetresult.go
index f1f143968..971f9c443 100644
--- a/internal/target/targetresult.go
+++ b/internal/target/targetresult.go
@@ -53,6 +53,8 @@ func UnmarshalTargetResultOptions(trName string, rawOptions json.RawMessage) (Ta
 		options = new(GCPTargetResultOptions)
 	case "org.osbuild.azure.image":
 		options = new(AzureImageTargetResultOptions)
+	case "org.osbuild.koji":
+		options = new(KojiTargetResultOptions)
 	case "org.osbuild.oci":
 		options = new(OCITargetResultOptions)
 	default:
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 8be0cd220..78cc38ab2 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -151,7 +151,7 @@ func New(repoPaths []string, stateDir string, solver *dnfjson.BaseSolver, dr *di
 		logger = log.New(os.Stdout, "", 0)
 	}
 
-	hostDistroName, _, _, err := distro.GetHostDistroName()
+	hostDistroName, _, _, err := common.GetHostDistroName()
 	if err != nil {
 		return nil, fmt.Errorf("failed to read host distro information")
 	}
diff --git a/internal/worker/clienterrors/errors.go b/internal/worker/clienterrors/errors.go
index 4831f1ba4..6fad4bec5 100644
--- a/internal/worker/clienterrors/errors.go
+++ b/internal/worker/clienterrors/errors.go
@@ -20,13 +20,13 @@ const (
 	ErrorInvalidConfig        ClientErrorCode = 17
 	ErrorOldResultCompatible  ClientErrorCode = 18
 	ErrorEmptyManifest        ClientErrorCode = 19
-
-	ErrorDNFDepsolveError  ClientErrorCode = 20
-	ErrorDNFMarkingErrors  ClientErrorCode = 21
-	ErrorDNFOtherError     ClientErrorCode = 22
-	ErrorRPMMDError        ClientErrorCode = 23
-	ErrorEmptyPackageSpecs ClientErrorCode = 24
-	ErrorDNFRepoError      ClientErrorCode = 25
+	ErrorDNFDepsolveError     ClientErrorCode = 20
+	ErrorDNFMarkingErrors     ClientErrorCode = 21
+	ErrorDNFOtherError        ClientErrorCode = 22
+	ErrorRPMMDError           ClientErrorCode = 23
+	ErrorEmptyPackageSpecs    ClientErrorCode = 24
+	ErrorDNFRepoError         ClientErrorCode = 25
+	ErrorJobDependency        ClientErrorCode = 26
 )
 
 type ClientErrorCode int
@@ -72,6 +72,8 @@ func GetStatusCode(err *Error) StatusCode {
 		return JobStatusUserInputError
 	case ErrorManifestDependency:
 		return JobStatusUserInputError
+	case ErrorJobDependency:
+		return JobStatusUserInputError
 	case ErrorEmptyPackageSpecs:
 		return JobStatusUserInputError
 	case ErrorEmptyManifest:
@@ -81,12 +83,17 @@ func GetStatusCode(err *Error) StatusCode {
 	}
 }
 
-func (e *Error) HasDependencyError() bool {
+// IsDependencyError returns true if the error means that a dependency of a job failed
+func (e *Error) IsDependencyError() bool {
 	switch e.ID {
 	case ErrorDepsolveDependency:
 		return true
 	case ErrorManifestDependency:
 		return true
+	case ErrorKojiFailedDependency:
+		return true
+	case ErrorJobDependency:
+		return true
 	default:
 		return false
 	}
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 3f6c64fc9..05d3cd877 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -16,9 +16,11 @@ import (
 //
 
 type OSBuildJob struct {
-	Manifest  distro.Manifest  `json:"manifest,omitempty"`
-	Targets   []*target.Target `json:"targets,omitempty"`
-	ImageName string           `json:"image_name,omitempty"`
+	Manifest distro.Manifest `json:"manifest,omitempty"`
+	// Index of the ManifestJobByIDResult instance in the job's dynamic arguments slice
+	ManifestDynArgsIdx *int             `json:"manifest_dyn_args_idx,omitempty"`
+	Targets            []*target.Target `json:"targets,omitempty"`
+	ImageName          string           `json:"image_name,omitempty"`
 
 	// TODO: Delete this after "some" time (kept for backward compatibility)
 	StreamOptimized bool `json:"stream_optimized,omitempty"`
@@ -38,6 +40,10 @@ type OSBuildJobResult struct {
 	TargetErrors  []string               `json:"target_errors,omitempty"`
 	UploadStatus  string                 `json:"upload_status"`
 	PipelineNames *PipelineNames         `json:"pipeline_names,omitempty"`
+	// Host OS of the worker which handled the job
+	HostOS string `json:"host_os"`
+	// Architecture of the worker which handled the job
+	Arch string `json:"arch"`
 	JobResult
 }
 
@@ -72,7 +78,7 @@ type OSBuildKojiJobResult struct {
 	PipelineNames *PipelineNames  `json:"pipeline_names,omitempty"`
 	ImageHash     string          `json:"image_hash"`
 	ImageSize     uint64          `json:"image_size"`
-	KojiError     string          `json:"koji_error"`
+	KojiError     string          `json:"koji_error"` // not set by any code other than unit tests
 	JobResult
 }
 
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 149ac82d0..0ad2386bb 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -28,6 +28,15 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
 )
 
+const (
+	JobTypeOSBuild        string = "osbuild"
+	JobTypeOSBuildKoji    string = "osbuild-koji"
+	JobTypeKojiInit       string = "koji-init"
+	JobTypeKojiFinalize   string = "koji-finalize"
+	JobTypeDepsolve       string = "depsolve"
+	JobTypeManifestIDOnly string = "manifest-id-only"
+)
+
 type Server struct {
 	jobs   jobqueue.JobQueue
 	logger *log.Logger
@@ -101,35 +110,35 @@ func (s *Server) WatchHeartbeats() {
 }
 
 func (s *Server) EnqueueOSBuild(arch string, job *OSBuildJob, channel string) (uuid.UUID, error) {
-	return s.enqueue("osbuild:"+arch, job, nil, channel)
+	return s.enqueue(JobTypeOSBuild+":"+arch, job, nil, channel)
 }
 
-func (s *Server) EnqueueOSBuildAsDependency(arch string, job *OSBuildJob, manifestID uuid.UUID, channel string) (uuid.UUID, error) {
-	return s.enqueue("osbuild:"+arch, job, []uuid.UUID{manifestID}, channel)
+func (s *Server) EnqueueOSBuildAsDependency(arch string, job *OSBuildJob, dependencies []uuid.UUID, channel string) (uuid.UUID, error) {
+	return s.enqueue(JobTypeOSBuild+":"+arch, job, dependencies, channel)
 }
 
 func (s *Server) EnqueueOSBuildKoji(arch string, job *OSBuildKojiJob, initID uuid.UUID, channel string) (uuid.UUID, error) {
-	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID}, channel)
+	return s.enqueue(JobTypeOSBuildKoji+":"+arch, job, []uuid.UUID{initID}, channel)
 }
 
 func (s *Server) EnqueueOSBuildKojiAsDependency(arch string, job *OSBuildKojiJob, manifestID, initID uuid.UUID, channel string) (uuid.UUID, error) {
-	return s.enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID, manifestID}, channel)
+	return s.enqueue(JobTypeOSBuildKoji+":"+arch, job, []uuid.UUID{initID, manifestID}, channel)
 }
 
 func (s *Server) EnqueueKojiInit(job *KojiInitJob, channel string) (uuid.UUID, error) {
-	return s.enqueue("koji-init", job, nil, channel)
+	return s.enqueue(JobTypeKojiInit, job, nil, channel)
 }
 
 func (s *Server) EnqueueKojiFinalize(job *KojiFinalizeJob, initID uuid.UUID, buildIDs []uuid.UUID, channel string) (uuid.UUID, error) {
-	return s.enqueue("koji-finalize", job, append([]uuid.UUID{initID}, buildIDs...), channel)
+	return s.enqueue(JobTypeKojiFinalize, job, append([]uuid.UUID{initID}, buildIDs...), channel)
 }
 
 func (s *Server) EnqueueDepsolve(job *DepsolveJob, channel string) (uuid.UUID, error) {
-	return s.enqueue("depsolve", job, nil, channel)
+	return s.enqueue(JobTypeDepsolve, job, nil, channel)
 }
 
 func (s *Server) EnqueueManifestJobByID(job *ManifestJobByID, parent uuid.UUID, channel string) (uuid.UUID, error) {
-	return s.enqueue("manifest-id-only", job, []uuid.UUID{parent}, channel)
+	return s.enqueue(JobTypeManifestIDOnly, job, []uuid.UUID{parent}, channel)
 }
 
 func (s *Server) enqueue(jobType string, job interface{}, dependencies []uuid.UUID, channel string) (uuid.UUID, error) {
@@ -137,32 +146,92 @@ func (s *Server) enqueue(jobType string, job interface{}, dependencies []uuid.UU
 	return s.jobs.Enqueue(jobType, job, dependencies, channel)
 }
 
-func (s *Server) CheckBuildDependencies(dep uuid.UUID, jobErr *clienterrors.Error) error {
-	var manifestJR ManifestJobByIDResult
-	_, deps, err := s.ManifestJobStatus(dep, &manifestJR)
+// DependencyChainErrors recursively gathers all errors from job's dependencies,
+// which caused it to fail. If the job didn't fail, `nil` is returned.
+func (s *Server) JobDependencyChainErrors(id uuid.UUID) (*clienterrors.Error, error) {
+	jobType, err := s.JobType(id)
 	if err != nil {
-		return err
+		return nil, err
 	}
 
-	if manifestJobErr := manifestJR.JobError; manifestJobErr != nil {
-		if !manifestJobErr.HasDependencyError() {
-			jobErr.Details = manifestJobErr
-			return nil
-		}
-		if len(deps) > 1 {
-			return fmt.Errorf("Unexpected number of dependencies received")
+	var jobResult *JobResult
+	var jobDeps []uuid.UUID
+	switch jobType {
+	case JobTypeOSBuild:
+		var osbuildJR OSBuildJobResult
+		_, jobDeps, err = s.OSBuildJobStatus(id, &osbuildJR)
+		if err != nil {
+			return nil, err
 		}
+		jobResult = &osbuildJR.JobResult
 
+	case JobTypeDepsolve:
 		var depsolveJR DepsolveJobResult
-		_, _, err := s.DepsolveJobStatus(deps[0], &depsolveJR)
+		_, jobDeps, err = s.DepsolveJobStatus(id, &depsolveJR)
 		if err != nil {
-			return err
+			return nil, err
 		}
+		jobResult = &depsolveJR.JobResult
 
-		jobErr.Details = depsolveJR.JobError
+	case JobTypeManifestIDOnly:
+		var manifestJR ManifestJobByIDResult
+		_, jobDeps, err = s.ManifestJobStatus(id, &manifestJR)
+		if err != nil {
+			return nil, err
+		}
+		jobResult = &manifestJR.JobResult
+
+	case JobTypeKojiInit:
+		var kojiInitJR KojiInitJobResult
+		_, jobDeps, err = s.KojiInitJobStatus(id, &kojiInitJR)
+		if err != nil {
+			return nil, err
+		}
+		jobResult = &kojiInitJR.JobResult
+
+	case JobTypeOSBuildKoji:
+		var osbuildKojiJR OSBuildKojiJobResult
+		_, jobDeps, err = s.OSBuildKojiJobStatus(id, &osbuildKojiJR)
+		if err != nil {
+			return nil, err
+		}
+		jobResult = &osbuildKojiJR.JobResult
+
+	case JobTypeKojiFinalize:
+		var kojiFinalizeJR KojiFinalizeJobResult
+		_, jobDeps, err = s.KojiFinalizeJobStatus(id, &kojiFinalizeJR)
+		if err != nil {
+			return nil, err
+		}
+		jobResult = &kojiFinalizeJR.JobResult
+
+	default:
+		return nil, fmt.Errorf("unexpected job type: %s", jobType)
+	}
+
+	if jobError := jobResult.JobError; jobError != nil {
+		depErrors := []*clienterrors.Error{}
+		if jobError.IsDependencyError() {
+			// check job's dependencies
+			for _, dep := range jobDeps {
+				depError, err := s.JobDependencyChainErrors(dep)
+				if err != nil {
+					return nil, err
+				}
+				if depError != nil {
+					depErrors = append(depErrors, depError)
+				}
+			}
+		}
+
+		if len(depErrors) > 0 {
+			jobError.Details = depErrors
+		}
+
+		return jobError, nil
 	}
 
-	return nil
+	return nil, nil
 }
 
 func (s *Server) OSBuildJobStatus(id uuid.UUID, result *OSBuildJobResult) (*JobStatus, []uuid.UUID, error) {
@@ -171,8 +240,8 @@ func (s *Server) OSBuildJobStatus(id uuid.UUID, result *OSBuildJobResult) (*JobS
 		return nil, nil, err
 	}
 
-	if !strings.HasPrefix(jobType, "osbuild:") { // Build jobs get automatic arch suffix: Check prefix
-		return nil, nil, fmt.Errorf("expected osbuild:*, found %q job instead", jobType)
+	if !strings.HasPrefix(jobType, JobTypeOSBuild+":") { // Build jobs get automatic arch suffix: Check prefix
+		return nil, nil, fmt.Errorf("expected \"%s:*\", found %q job instead", JobTypeOSBuild, jobType)
 	}
 
 	if result.JobError == nil && !status.Finished.IsZero() {
@@ -199,8 +268,8 @@ func (s *Server) OSBuildKojiJobStatus(id uuid.UUID, result *OSBuildKojiJobResult
 		return nil, nil, err
 	}
 
-	if !strings.HasPrefix(jobType, "osbuild-koji:") { // Build jobs get automatic arch suffix: Check prefix
-		return nil, nil, fmt.Errorf("expected \"osbuild-koji:*\", found %q job instead", jobType)
+	if !strings.HasPrefix(jobType, JobTypeOSBuildKoji+":") { // Build jobs get automatic arch suffix: Check prefix
+		return nil, nil, fmt.Errorf("expected \"%s:*\", found %q job instead", JobTypeOSBuildKoji, jobType)
 	}
 
 	if result.JobError == nil && !status.Finished.IsZero() {
@@ -222,8 +291,8 @@ func (s *Server) KojiInitJobStatus(id uuid.UUID, result *KojiInitJobResult) (*Jo
 		return nil, nil, err
 	}
 
-	if jobType != "koji-init" {
-		return nil, nil, fmt.Errorf("expected \"koji-init\", found %q job instead", jobType)
+	if jobType != JobTypeKojiInit {
+		return nil, nil, fmt.Errorf("expected %q, found %q job instead", JobTypeKojiInit, jobType)
 	}
 
 	if result.JobError == nil && result.KojiError != "" {
@@ -239,8 +308,8 @@ func (s *Server) KojiFinalizeJobStatus(id uuid.UUID, result *KojiFinalizeJobResu
 		return nil, nil, err
 	}
 
-	if jobType != "koji-finalize" {
-		return nil, nil, fmt.Errorf("expected \"koji-finalize\", found %q job instead", jobType)
+	if jobType != JobTypeKojiFinalize {
+		return nil, nil, fmt.Errorf("expected %q, found %q job instead", JobTypeKojiFinalize, jobType)
 	}
 
 	if result.JobError == nil && result.KojiError != "" {
@@ -256,8 +325,8 @@ func (s *Server) DepsolveJobStatus(id uuid.UUID, result *DepsolveJobResult) (*Jo
 		return nil, nil, err
 	}
 
-	if jobType != "depsolve" {
-		return nil, nil, fmt.Errorf("expected \"depsolve\", found %q job instead", jobType)
+	if jobType != JobTypeDepsolve {
+		return nil, nil, fmt.Errorf("expected %q, found %q job instead", JobTypeDepsolve, jobType)
 	}
 
 	if result.JobError == nil && result.Error != "" {
@@ -277,8 +346,8 @@ func (s *Server) ManifestJobStatus(id uuid.UUID, result *ManifestJobByIDResult)
 		return nil, nil, err
 	}
 
-	if jobType != "manifest-id-only" {
-		return nil, nil, fmt.Errorf("expected \"manifest-id-only\", found %q job instead", jobType)
+	if jobType != JobTypeManifestIDOnly {
+		return nil, nil, fmt.Errorf("expected %q, found %q job instead", JobTypeManifestIDOnly, jobType)
 	}
 
 	return status, deps, nil
@@ -312,8 +381,8 @@ func (s *Server) OSBuildJob(id uuid.UUID, job *OSBuildJob) error {
 		return err
 	}
 
-	if !strings.HasPrefix(jobType, "osbuild:") { // Build jobs get automatic arch suffix: Check prefix
-		return fmt.Errorf("expected osbuild:*, found %q job instead for job '%s'", jobType, id)
+	if !strings.HasPrefix(jobType, JobTypeOSBuild+":") { // Build jobs get automatic arch suffix: Check prefix
+		return fmt.Errorf("expected %s:*, found %q job instead for job '%s'", JobTypeOSBuild, jobType, id)
 	}
 
 	if err := json.Unmarshal(rawArgs, job); err != nil {
@@ -330,8 +399,8 @@ func (s *Server) OSBuildKojiJob(id uuid.UUID, job *OSBuildKojiJob) error {
 		return err
 	}
 
-	if !strings.HasPrefix(jobType, "osbuild-koji:") { // Build jobs get automatic arch suffix: Check prefix
-		return fmt.Errorf("expected osbuild-koji:*, found %q job instead for job '%s'", jobType, id)
+	if !strings.HasPrefix(jobType, JobTypeOSBuildKoji+":") { // Build jobs get automatic arch suffix: Check prefix
+		return fmt.Errorf("expected %s:*, found %q job instead for job '%s'", JobTypeOSBuildKoji, jobType, id)
 	}
 
 	if err := json.Unmarshal(rawArgs, job); err != nil {
@@ -341,6 +410,11 @@ func (s *Server) OSBuildKojiJob(id uuid.UUID, job *OSBuildKojiJob) error {
 	return nil
 }
 
+func (s *Server) JobChannel(id uuid.UUID) (string, error) {
+	_, _, _, channel, err := s.jobs.Job(id)
+	return channel, err
+}
+
 // JobType returns the type of the job
 func (s *Server) JobType(id uuid.UUID) (string, error) {
 	jobType, _, _, _, err := s.jobs.Job(id)
@@ -422,10 +496,10 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 	// restriction: arch for osbuild jobs.
 	jts := []string{}
 	for _, t := range jobTypes {
-		if t == "osbuild" || t == "osbuild-koji" {
+		if t == JobTypeOSBuild || t == JobTypeOSBuildKoji {
 			t = t + ":" + arch
 		}
-		if t == "manifest-id-only" {
+		if t == JobTypeManifestIDOnly {
 			return uuid.Nil, uuid.Nil, "", nil, nil, ErrInvalidJobType
 		}
 		jts = append(jts, t)
@@ -486,10 +560,10 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 	// TODO: Drop the ':$architecture' for metrics too, first prometheus queries for alerts and
 	// dashboards need to be adjusted.
 	prometheus.DequeueJobMetrics(pending, status.Started, jobType, channel)
-	if jobType == "osbuild:"+arch {
-		jobType = "osbuild"
-	} else if jobType == "osbuild-koji:"+arch {
-		jobType = "osbuild-koji"
+	if jobType == JobTypeOSBuild+":"+arch {
+		jobType = JobTypeOSBuild
+	} else if jobType == JobTypeOSBuildKoji+":"+arch {
+		jobType = JobTypeOSBuildKoji
 	}
 
 	return


Test patch:
diff --git a/internal/cloudapi/v2/v2_koji_test.go b/internal/cloudapi/v2/v2_koji_test.go
index 5f3a494b0..4d4e1df18 100644
--- a/internal/cloudapi/v2/v2_koji_test.go
+++ b/internal/cloudapi/v2/v2_koji_test.go
@@ -15,7 +15,9 @@ import (
 	v2 "github.com/osbuild/osbuild-composer/internal/cloudapi/v2"
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	"github.com/osbuild/osbuild-composer/internal/kojiapi/api"
+	"github.com/osbuild/osbuild-composer/internal/osbuild2"
 	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild2"
+	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
@@ -33,7 +35,7 @@ func TestKojiCompose(t *testing.T) {
 
 	type kojiCase struct {
 		initResult       worker.KojiInitJobResult
-		buildResult      worker.OSBuildKojiJobResult
+		buildResult      worker.OSBuildJobResult
 		finalizeResult   worker.KojiFinalizeJobResult
 		composeReplyCode int
 		composeReply     string
@@ -41,16 +43,19 @@ func TestKojiCompose(t *testing.T) {
 	}
 
 	var cases = []kojiCase{
+		// #0
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -76,15 +81,18 @@ func TestKojiCompose(t *testing.T) {
 				"status": "success"
 			}`,
 		},
+		// #1
 		{
 			initResult: worker.KojiInitJobResult{
 				KojiError: "failure",
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -108,17 +116,20 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #2
 		{
 			initResult: worker.KojiInitJobResult{
 				JobResult: worker.JobResult{
 					JobError: clienterrors.WorkerClientError(clienterrors.ErrorKojiInit, "Koji init error"),
 				},
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -142,58 +153,25 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #3
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: false,
 				},
 			},
 			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
-			composeStatus: `{
-				"kind": "ComposeStatus",
-				"image_status": {
-					"status": "failure"
-				},
-				"image_statuses": [
-					{
-						"status": "failure"
-					},
-					{
-						"status": "success"
-					}
-				],
-				"koji_status": {
-					"build_id": 42
-				},
-				"status": "failure"
-			}`,
-		},
-		{
-			initResult: worker.KojiInitJobResult{
-				BuildID: 42,
-				Token:   `"foobar"`,
-			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
-				OSBuildOutput: &osbuild.Result{
-					Success: true,
-				},
-				KojiError: "failure",
-			},
-			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
 			composeStatus: `{
 				"kind": "ComposeStatus",
 				"image_status": {
@@ -213,16 +191,19 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #4
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -231,15 +212,25 @@ func TestKojiCompose(t *testing.T) {
 				},
 			},
 			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
 			composeStatus: `{
 				"kind": "ComposeStatus",
 				"image_status": {
-					"status": "failure"
+					"status": "failure",
+					"error": {
+						"details": null,
+						"id": 10,
+						"reason": "Koji build error"
+					}
 				},
 				"image_statuses": [
 					{
-						"status": "failure"
+						"status": "failure",
+						"error": {
+							"details": null,
+							"id": 10,
+							"reason": "Koji build error"
+						}
 					},
 					{
 						"status": "success"
@@ -251,16 +242,19 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #5
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -269,7 +263,7 @@ func TestKojiCompose(t *testing.T) {
 				KojiError: "failure",
 			},
 			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
 			composeStatus: `{
 				"kind": "ComposeStatus",
 				"image_status": {
@@ -289,16 +283,19 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #6
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -309,7 +306,7 @@ func TestKojiCompose(t *testing.T) {
 				},
 			},
 			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
 			composeStatus: `{
 				"kind": "ComposeStatus",
 				"image_status": {
@@ -329,16 +326,19 @@ func TestKojiCompose(t *testing.T) {
 				"status": "failure"
 			}`,
 		},
+		// #7
 		{
 			initResult: worker.KojiInitJobResult{
 				BuildID: 42,
 				Token:   `"foobar"`,
 			},
-			buildResult: worker.OSBuildKojiJobResult{
-				Arch:      test_distro.TestArchName,
-				HostOS:    test_distro.TestDistroName,
-				ImageHash: "browns",
-				ImageSize: 42,
+			buildResult: worker.OSBuildJobResult{
+				Arch:   test_distro.TestArchName,
+				HostOS: test_distro.TestDistroName,
+				TargetResults: []*target.TargetResult{target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+					ImageMD5:  "browns",
+					ImageSize: 42,
+				})},
 				OSBuildOutput: &osbuild.Result{
 					Success: true,
 				},
@@ -354,27 +354,33 @@ func TestKojiCompose(t *testing.T) {
 				},
 			},
 			composeReplyCode: http.StatusCreated,
-			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
 			composeStatus: `{
 				"kind": "ComposeStatus",
 				"image_status": {
 					"error": {
-						"details": {
-							"id": 22,
-							"reason": "DNF Error"
-						},
+						"details": [
+							{
+								"id": 22,
+								"details": null,
+								"reason": "DNF Error"
+							}
+						],
 						"id": 9,
 						"reason": "Manifest dependency failed"
 					},
-					"status": "failure",
+					"status": "failure"
 				},
 				"image_statuses": [
 					{
 						"error": {
-							"details": {
-								"id": 22,
-								"reason": "Error in depsolve job"
-							},
+							"details": [
+								{
+									"id": 22,
+									"details": null,
+									"reason": "DNF Error"
+								}
+							],
 							"id": 9,
 							"reason": "Manifest dependency failed"
 						},
@@ -391,8 +397,9 @@ func TestKojiCompose(t *testing.T) {
 			}`,
 		},
 	}
-	for _, c := range cases[2:3] {
-		test.TestRoute(t, handler, false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	for idx, c := range cases {
+		t.Run(fmt.Sprintf("Test case #%d", idx), func(t *testing.T) {
+			composeRawReply := test.TestRouteWithReply(t, handler, false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 		{
 			"distribution":"%[1]s",
 			"image_requests": [
@@ -423,97 +430,111 @@ func TestKojiCompose(t *testing.T) {
 				"task_id": 42
 			}
 		}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypesGuestImage)),
-			c.composeReplyCode, c.composeReply, "id", "operation_id")
-
-		// handle koji-init
-		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-init"}, []string{""})
-		require.NoError(t, err)
-		require.Equal(t, "koji-init", jobType)
-
-		var initJob worker.KojiInitJob
-		err = json.Unmarshal(rawJob, &initJob)
-		require.NoError(t, err)
-		require.Equal(t, "koji.example.com", initJob.Server)
-		require.Equal(t, "foo", initJob.Name)
-		require.Equal(t, "1", initJob.Version)
-		require.Equal(t, "2", initJob.Release)
-
-		initJobResult, err := json.Marshal(&jobResult{Result: c.initResult})
-		require.NoError(t, err)
-		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(initJobResult), http.StatusOK,
-			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
-
-		// handle osbuild-koji #1
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"}, []string{""})
-		require.NoError(t, err)
-		require.Equal(t, "osbuild-koji", jobType)
-
-		var osbuildJob worker.OSBuildKojiJob
-		err = json.Unmarshal(rawJob, &osbuildJob)
-		require.NoError(t, err)
-		require.Equal(t, "koji.example.com", osbuildJob.KojiServer)
-		require.Equal(t, "test.img", osbuildJob.ImageName)
-		require.NotEmpty(t, osbuildJob.KojiDirectory)
-
-		buildJobResult, err := json.Marshal(&jobResult{Result: c.buildResult})
-		require.NoError(t, err)
-		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(buildJobResult), http.StatusOK,
-			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
-
-		// handle osbuild-koji #2
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild-koji"}, []string{""})
-		require.NoError(t, err)
-		require.Equal(t, "osbuild-koji", jobType)
-
-		err = json.Unmarshal(rawJob, &osbuildJob)
-		require.NoError(t, err)
-		require.Equal(t, "koji.example.com", osbuildJob.KojiServer)
-		require.Equal(t, "test.img", osbuildJob.ImageName)
-		require.NotEmpty(t, osbuildJob.KojiDirectory)
-
-		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), fmt.Sprintf(`{
-				"result": {
-					"arch": "%s",
-					"host_os": "%s",
-					"image_hash": "browns",
-					"image_size": 42,
-					"osbuild_output": {
-						"success": true
-					}
+				c.composeReplyCode, c.composeReply, "id", "operation_id")
+
+			// determine the compose ID from the reply
+			var composeReply v2.ComposeId
+			err := json.Unmarshal(composeRawReply, &composeReply)
+			require.NoError(t, err)
+			composeId, err := uuid.Parse(composeReply.Id)
+			require.NoError(t, err)
+
+			// handle koji-init
+			_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeKojiInit}, []string{""})
+			require.NoError(t, err)
+			require.Equal(t, worker.JobTypeKojiInit, jobType)
+
+			var initJob worker.KojiInitJob
+			err = json.Unmarshal(rawJob, &initJob)
+			require.NoError(t, err)
+			require.Equal(t, "koji.example.com", initJob.Server)
+			require.Equal(t, "foo", initJob.Name)
+			require.Equal(t, "1", initJob.Version)
+			require.Equal(t, "2", initJob.Release)
+
+			initJobResult, err := json.Marshal(&jobResult{Result: c.initResult})
+			require.NoError(t, err)
+			test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(initJobResult), http.StatusOK,
+				fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
+
+			// Finishing of the goroutine handling the manifest job is not deterministic and as a result, we may get
+			// the second osbuild job first.
+			// The build jobs ID is determined from the dependencies of the koji-finalize job dependencies.
+			_, finalizeJobDeps, err := workerServer.KojiFinalizeJobStatus(composeId, &worker.KojiFinalizeJobResult{})
+			require.NoError(t, err)
+			buildJobIDs := finalizeJobDeps[1:]
+			require.Len(t, buildJobIDs, 2)
+
+			// handle build jobs
+			for i := 0; i < len(buildJobIDs); i++ {
+				jobID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
+				require.NoError(t, err)
+				require.Equal(t, worker.JobTypeOSBuild, jobType)
+
+				var osbuildJob worker.OSBuildJob
+				err = json.Unmarshal(rawJob, &osbuildJob)
+				require.NoError(t, err)
+				jobTarget := osbuildJob.Targets[0].Options.(*target.KojiTargetOptions)
+				require.Equal(t, "koji.example.com", jobTarget.Server)
+				require.Equal(t, "test.img", osbuildJob.ImageName)
+				require.NotEmpty(t, jobTarget.UploadDirectory)
+
+				var buildJobResult string
+				switch jobID {
+				// use the build job result from the test case only for the first job
+				case buildJobIDs[0]:
+					buildJobResultBytes, err := json.Marshal(&jobResult{Result: c.buildResult})
+					require.NoError(t, err)
+					buildJobResult = string(buildJobResultBytes)
+				default:
+					buildJobResult = fmt.Sprintf(`{
+						"result": {
+							"arch": "%s",
+							"host_os": "%s",
+							"image_hash": "browns",
+							"image_size": 42,
+							"osbuild_output": {
+								"success": true
+							}
+						}
+					}`, test_distro.TestArch3Name, test_distro.TestDistroName)
 				}
-			}`, test_distro.TestArch3Name, test_distro.TestDistroName), http.StatusOK,
-			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
-
-		// handle koji-finalize
-		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"koji-finalize"}, []string{""})
-		require.NoError(t, err)
-		require.Equal(t, "koji-finalize", jobType)
 
-		var kojiFinalizeJob worker.KojiFinalizeJob
-		err = json.Unmarshal(rawJob, &kojiFinalizeJob)
-		require.NoError(t, err)
-		require.Equal(t, "koji.example.com", kojiFinalizeJob.Server)
-		require.Equal(t, "1", kojiFinalizeJob.Version)
-		require.Equal(t, "2", kojiFinalizeJob.Release)
-		require.ElementsMatch(t, []string{
-			fmt.Sprintf("foo-1-2.%s.img", test_distro.TestArch3Name),
-			fmt.Sprintf("foo-1-2.%s.img", test_distro.TestArch3Name),
-		}, kojiFinalizeJob.KojiFilenames)
-		require.NotEmpty(t, kojiFinalizeJob.KojiDirectory)
-
-		finalizeResult, err := json.Marshal(&jobResult{Result: c.finalizeResult})
-		require.NoError(t, err)
-		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(finalizeResult), http.StatusOK,
-			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
-
-		// get the status
-		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", finalizeID), ``, http.StatusOK, c.composeStatus, `href`, `id`)
-
-		// get the manifests
-		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/manifests", finalizeID), ``, http.StatusOK, `{"manifests":[null,null],"kind":"ComposeManifests"}`, `href`, `id`)
+				test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), buildJobResult, http.StatusOK,
+					fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
+			}
 
-		// get the logs
-		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", finalizeID), ``, http.StatusOK, `{"kind":"ComposeLogs"}`, `koji`, `image_builds`, `href`, `id`)
+			// handle koji-finalize
+			finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeKojiFinalize}, []string{""})
+			require.NoError(t, err)
+			require.Equal(t, worker.JobTypeKojiFinalize, jobType)
+
+			var kojiFinalizeJob worker.KojiFinalizeJob
+			err = json.Unmarshal(rawJob, &kojiFinalizeJob)
+			require.NoError(t, err)
+			require.Equal(t, "koji.example.com", kojiFinalizeJob.Server)
+			require.Equal(t, "1", kojiFinalizeJob.Version)
+			require.Equal(t, "2", kojiFinalizeJob.Release)
+			require.ElementsMatch(t, []string{
+				fmt.Sprintf("foo-1-2.%s.img", test_distro.TestArch3Name),
+				fmt.Sprintf("foo-1-2.%s.img", test_distro.TestArch3Name),
+			}, kojiFinalizeJob.KojiFilenames)
+			require.NotEmpty(t, kojiFinalizeJob.KojiDirectory)
+
+			finalizeResult, err := json.Marshal(&jobResult{Result: c.finalizeResult})
+			require.NoError(t, err)
+			test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(finalizeResult), http.StatusOK,
+				fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
+
+			// get the status
+			test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", finalizeID), ``, http.StatusOK, c.composeStatus, `href`, `id`)
+
+			// get the manifests
+			test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/manifests", finalizeID), ``, http.StatusOK, `{"manifests":[{"pipeline":{},"sources":{}},{"pipeline":{},"sources":{}}],"kind":"ComposeManifests"}`, `href`, `id`)
+
+			// get the logs
+			test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", finalizeID), ``, http.StatusOK, `{"kind":"ComposeLogs"}`, `koji`, `image_builds`, `href`, `id`)
+		})
 	}
 }
 
@@ -565,18 +586,29 @@ func TestKojiJobTypeValidation(t *testing.T) {
 	initID, err := workers.EnqueueKojiInit(&initJob, "")
 	require.NoError(t, err)
 
-	buildJobs := make([]worker.OSBuildKojiJob, nImages)
+	manifest, err := json.Marshal(osbuild2.Manifest{})
+	require.NoErrorf(t, err, "error marshalling empty Manifest to JSON")
+
+	buildJobs := make([]worker.OSBuildJob, nImages)
 	buildJobIDs := make([]uuid.UUID, nImages)
 	filenames := make([]string, nImages)
 	for idx := 0; idx < nImages; idx++ {
 		fname := fmt.Sprintf("image-file-%04d", idx)
-		buildJob := worker.OSBuildKojiJob{
-			ImageName:     fmt.Sprintf("build-job-%04d", idx),
-			KojiServer:    "test-server",
-			KojiDirectory: "koji-server-test-dir",
-			KojiFilename:  fname,
+		buildJob := worker.OSBuildJob{
+			ImageName: fmt.Sprintf("build-job-%04d", idx),
+			Targets: []*target.Target{target.NewKojiTarget(&target.KojiTargetOptions{
+				Server:          "test-server",
+				UploadDirectory: "koji-server-test-dir",
+				Filename:        fname,
+			})},
+			// Add an empty manifest as a static job argument to make the test pass.
+			// Becasue of a bug in the API, the test was passing even without
+			// any manifest being attached to the job (static or dynamic).
+			// In reality, cloudapi never adds the manifest as a static job argument.
+			// TODO: use dependent depsolve and manifests jobs instead
+			Manifest: manifest,
 		}
-		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID, "")
+		buildID, err := workers.EnqueueOSBuildAsDependency(fmt.Sprintf("fake-arch-%d", idx), &buildJob, []uuid.UUID{initID}, "")
 		require.NoError(t, err)
 
 		buildJobs[idx] = buildJob
@@ -599,6 +631,9 @@ func TestKojiJobTypeValidation(t *testing.T) {
 
 	// ----- Jobs queued - Test API endpoints (status, manifests, logs) ----- //
 
+	t.Logf("%q job ID: %s", worker.JobTypeKojiInit, initID)
+	t.Logf("%q job ID: %s", worker.JobTypeKojiFinalize, finalizeID)
+	t.Logf("%q job IDs: %v", worker.JobTypeOSBuildKoji, buildJobIDs)
 	for _, path := range []string{"", "/manifests", "/logs"} {
 		// should return OK - actual result should be tested elsewhere
 		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", finalizeID, path), ``, http.StatusOK, "*")
@@ -607,7 +642,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", initID, path), ``, http.StatusNotFound, `{"code":"IMAGE-BUILDER-COMPOSER-26","href":"/api/image-builder-composer/v2/errors/26","id":"26","kind":"Error","reason":"Requested job has invalid type"}`, `operation_id`)
 
 		for _, buildID := range buildJobIDs {
-			test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", buildID, path), ``, http.StatusNotFound, `{"code":"IMAGE-BUILDER-COMPOSER-26","href":"/api/image-builder-composer/v2/errors/26","id":"26","kind":"Error","reason":"Requested job has invalid type"}`, `operation_id`)
+			test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", buildID, path), ``, http.StatusOK, "*")
 		}
 
 		badID := uuid.New()
diff --git a/internal/cloudapi/v2/v2_multi_tenancy_test.go b/internal/cloudapi/v2/v2_multi_tenancy_test.go
index 8f7f44c86..b80b15c63 100644
--- a/internal/cloudapi/v2/v2_multi_tenancy_test.go
+++ b/internal/cloudapi/v2/v2_multi_tenancy_test.go
@@ -17,6 +17,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro/test_distro"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	"github.com/osbuild/osbuild-composer/internal/test"
+	"github.com/osbuild/osbuild-composer/internal/worker"
 )
 
 func kojiRequest() string {
@@ -121,14 +122,20 @@ func jobRequest() string {
 	return fmt.Sprintf(`
 		{
 			"types": [
-				"koji-init",
-				"osbuild",
-				"osbuild-koji",
-				"koji-finalize",
-				"depsolve"
+				%q,
+				%q,
+				%q,
+				%q,
+				%q
 			],
-			"arch": "%s"
-		}`, test_distro.TestArch3Name)
+			"arch": %q
+		}`,
+		worker.JobTypeKojiInit,
+		worker.JobTypeOSBuild,
+		worker.JobTypeOSBuildKoji,
+		worker.JobTypeKojiFinalize,
+		worker.JobTypeDepsolve,
+		test_distro.TestArch3Name)
 }
 
 func runNextJob(t *testing.T, jobs []uuid.UUID, workerHandler http.Handler, orgID string) {
@@ -260,6 +267,7 @@ func TestMultitenancy(t *testing.T) {
 		resp := test.APICall{
 			Handler:        handler,
 			Method:         http.MethodGet,
+			Context:        reqContext(c.orgID),
 			Path:           "/api/image-builder-composer/v2/composes/" + c.id.String(),
 			ExpectedStatus: http.StatusOK,
 		}.Do(t)
@@ -268,5 +276,45 @@ func TestMultitenancy(t *testing.T) {
 		}
 		require.NoError(t, json.Unmarshal(resp.Body, &result))
 		require.NotEqual(t, "pending", result.Status)
+
+		composeEndpoints := []string{"", "logs", "manifests", "metadata"}
+		// Verify that all compose endpoints work with the appropriate orgID
+		for _, endpoint := range composeEndpoints {
+			// TODO: "metadata" endpoint is not supported for Koji composes
+			jobType, err := workerServer.JobType(c.id)
+			require.NoError(t, err)
+			if jobType == worker.JobTypeKojiFinalize && endpoint == "metadata" {
+				continue
+			}
+
+			path := "/api/image-builder-composer/v2/composes/" + c.id.String()
+			if endpoint != "" {
+				path = path + "/" + endpoint
+			}
+
+			_ = test.APICall{
+				Handler:        handler,
+				Method:         http.MethodGet,
+				Context:        reqContext(c.orgID),
+				Path:           path,
+				ExpectedStatus: http.StatusOK,
+			}.Do(t)
+		}
+
+		// Verify that no compose endpoints are accessible with wrong orgID
+		for _, endpoint := range composeEndpoints {
+			path := "/api/image-builder-composer/v2/composes/" + c.id.String()
+			if endpoint != "" {
+				path = path + "/" + endpoint
+			}
+
+			_ = test.APICall{
+				Handler:        handler,
+				Method:         http.MethodGet,
+				Context:        reqContext("bad-org"),
+				Path:           path,
+				ExpectedStatus: http.StatusNotFound,
+			}.Do(t)
+		}
 	}
 }
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index e67a02a20..1cb18790d 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -47,7 +47,7 @@ func newV2Server(t *testing.T, dir string, depsolveChannels []string, enableJWT
 	go func() {
 		defer wg.Done()
 		for {
-			_, token, _, _, _, err := workerServer.RequestJob(depsolveContext, test_distro.TestDistroName, []string{"depsolve"}, depsolveChannels)
+			_, token, _, _, _, err := workerServer.RequestJob(depsolveContext, test_distro.TestDistroName, []string{worker.JobTypeDepsolve}, depsolveChannels)
 			select {
 			case <-depsolveContext.Done():
 				return
@@ -572,9 +572,9 @@ func TestComposeStatusSuccess(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, args, dynArgs, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
+	jobId, token, jobType, args, dynArgs, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
-	require.Equal(t, "osbuild", jobType)
+	require.Equal(t, worker.JobTypeOSBuild, jobType)
 
 	var osbuildJob worker.OSBuildJob
 	err = json.Unmarshal(args, &osbuildJob)
@@ -593,7 +593,7 @@ func TestComposeStatusSuccess(t *testing.T) {
 
 	res, err := json.Marshal(&worker.OSBuildJobResult{
 		Success:       true,
-		OSBuildOutput: &osbuild2.Result{},
+		OSBuildOutput: &osbuild2.Result{Success: true},
 	})
 	require.NoError(t, err)
 
@@ -616,6 +616,49 @@ func TestComposeStatusSuccess(t *testing.T) {
 		"code": "IMAGE-BUILDER-COMPOSER-1012",
 		"reason": "OSBuildJobResult does not have expected fields set"
 	}`, "operation_id")
+
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/logs", jobId), ``, http.StatusOK, fmt.Sprintf(`
+	{
+		"href": "/api/image-builder-composer/v2/composes/%v/logs",
+		"id": "%v",
+		"kind": "ComposeLogs",
+		"image_builds": [
+			{
+				"arch": "",
+				"host_os": "",
+				"osbuild_output": {
+					"log": null,
+					"metadata": null,
+					"success": true,
+					"type": ""
+				},
+				"pipeline_names": {
+					"build": [
+						"build"
+					],
+					"payload": [
+						"os",
+						"assembler"
+					]
+				},
+				"success": true,
+				"upload_status": ""
+			}
+		]
+	}`, jobId, jobId))
+
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/manifests", jobId), ``, http.StatusOK, fmt.Sprintf(`
+	{
+		"href": "/api/image-builder-composer/v2/composes/%v/manifests",
+		"id": "%v",
+		"kind": "ComposeManifests",
+		"manifests": [
+			{
+				"pipeline": {},
+				"sources": {}
+			}
+		]
+	}`, jobId, jobId))
 }
 
 func TestComposeStatusFailure(t *testing.T) {
@@ -642,9 +685,9 @@ func TestComposeStatusFailure(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
-	require.Equal(t, "osbuild", jobType)
+	require.Equal(t, worker.JobTypeOSBuild, jobType)
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
 	{
@@ -698,9 +741,9 @@ func TestComposeLegacyError(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
-	require.Equal(t, "osbuild", jobType)
+	require.Equal(t, worker.JobTypeOSBuild, jobType)
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
 	{
@@ -757,9 +800,9 @@ func TestComposeJobError(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
-	require.Equal(t, "osbuild", jobType)
+	require.Equal(t, worker.JobTypeOSBuild, jobType)
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
 	{
@@ -819,9 +862,9 @@ func TestComposeDependencyError(t *testing.T) {
 		"kind": "ComposeId"
 	}`, "id")
 
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{"osbuild"}, []string{""})
+	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
-	require.Equal(t, "osbuild", jobType)
+	require.Equal(t, worker.JobTypeOSBuild, jobType)
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
 	{
diff --git a/internal/distro/osrelease_test.go b/internal/common/distro_test.go
similarity index 98%
rename from internal/distro/osrelease_test.go
rename to internal/common/distro_test.go
index 490040bf3..376874052 100644
--- a/internal/distro/osrelease_test.go
+++ b/internal/common/distro_test.go
@@ -1,4 +1,4 @@
-package distro
+package common
 
 import (
 	"reflect"
diff --git a/internal/dnfjson/cache_test.go b/internal/dnfjson/cache_test.go
new file mode 100644
index 000000000..5dfa05455
--- /dev/null
+++ b/internal/dnfjson/cache_test.go
@@ -0,0 +1,275 @@
+package dnfjson
+
+import (
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func truncate(path string, size int64) {
+	fp, err := os.Create(path)
+	if err != nil {
+		panic(err)
+	}
+	defer fp.Close()
+	if err := fp.Truncate(size); err != nil {
+		panic(err)
+	}
+}
+
+// create a test cache based on the config, where the config keys are file
+// paths and the values are file sizes
+func createTestCache(root string, config testCache) uint64 {
+	var totalSize uint64
+	for path, fi := range config {
+		fullPath := filepath.Join(root, path)
+		parPath := filepath.Dir(fullPath)
+		if err := os.MkdirAll(parPath, 0770); err != nil {
+			panic(err)
+		}
+		truncate(fullPath, int64(fi.size))
+		mtime := time.Unix(fi.mtime, 0)
+		if err := os.Chtimes(fullPath, mtime, mtime); err != nil {
+			panic(err)
+		}
+
+		// if the path has multiple parts, touch the top level directory of the
+		// element
+		pathParts := strings.Split(path, "/")
+		if len(pathParts) > 1 {
+			top := pathParts[0]
+			if err := os.Chtimes(filepath.Join(root, top), mtime, mtime); err != nil {
+				panic(err)
+			}
+		}
+		if len(path) >= 64 {
+			// paths with shorter names will be ignored by the cache manager
+			totalSize += fi.size
+		}
+	}
+
+	// add directory sizes to total
+	sizer := func(path string, info fs.FileInfo, err error) error {
+		if err != nil {
+			return err
+		}
+		if path == root {
+			// don't count root
+			return nil
+		}
+		if info.IsDir() {
+			totalSize += uint64(info.Size())
+		}
+		return nil
+	}
+	if err := filepath.Walk(root, sizer); err != nil {
+		panic(err)
+	}
+
+	return totalSize
+}
+
+type fileInfo struct {
+	size  uint64
+	mtime int64
+}
+
+type testCache map[string]fileInfo
+
+var testCfgs = map[string]testCache{
+	"rhel84-aarch64": { // real repo metadata file names and sizes
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc.solv":                                                                                                                      fileInfo{2095095, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-filenames.solvx":                                                                                                           fileInfo{14473401, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/gen/groups.xml":                                                                                  fileInfo{1419587, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/3eabd1122210e4def18ae4b96a18aa5bcc186abf2ec14e2e8f1c1bb1ab4d11da-modules.yaml.gz":                fileInfo{156314, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/90fd2e7463220a07457e76ae905e1bad754c29e22202bb3202c971a5ece28396-comps-AppStream.aarch64.xml.gz": fileInfo{199426, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/77a66c76b5f6ba51aaee6c0cf76d701601e8b622d1701d1781dabec434f27413-filelists.xml.gz":               fileInfo{14370201, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/1941c723c94218eed43eac3174aa94cefbe921e15547c39251a95895024207ca-primary.xml.gz":                 fileInfo{11439375, 100},
+		"9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc-33d346d177279673/repodata/repomd.xml":                                                                                      fileInfo{13285, 100},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62.solv":                                                                                                                      fileInfo{1147863, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-filenames.solvx":                                                                                                           fileInfo{11133964, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-98177081b9162766/repodata/gen/groups.xml":                                                                                  fileInfo{1298102, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-98177081b9162766/repodata/d74783221709ab27d543c1cfc4c02562fde6edfaaaac33ac73a68ecf53188695-comps-BaseOS.aarch64.xml.gz":    fileInfo{174076, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-98177081b9162766/repodata/5ded48b4c9e238288130c6670d99f5febdb7273e4a31ac213836a15a2076514d-filelists.xml.gz":               fileInfo{11081612, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-98177081b9162766/repodata/8120caf8ebbb8c8b37f6f0dd027d866020ebe7acf9c9ce49ae9903b761986f0c-primary.xml.gz":                 fileInfo{1836471, 300},
+		"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62-98177081b9162766/repodata/repomd.xml":                                                                                      fileInfo{12817, 300},
+	},
+	"fake-real": { // fake but resembling real data
+		"1111111111111111111111111111111111111111111111111111111111111111.solv":           fileInfo{100, 0},
+		"1111111111111111111111111111111111111111111111111111111111111111-filenames.solv": fileInfo{200, 0},
+		"1111111111111111111111111111111111111111111111111111111111111111.whatever":       fileInfo{110, 0},
+		"1111111111111111111111111111111111111111111111111111111111111111/repodata/a":     fileInfo{1000, 0},
+		"1111111111111111111111111111111111111111111111111111111111111111/repodata/b":     fileInfo{3829, 0},
+		"1111111111111111111111111111111111111111111111111111111111111111/repodata/c":     fileInfo{831989, 0},
+		"2222222222222222222222222222222222222222222222222222222222222222.solv":           fileInfo{120, 2},
+		"2222222222222222222222222222222222222222222222222222222222222222-filenames.solv": fileInfo{232, 2},
+		"2222222222222222222222222222222222222222222222222222222222222222.whatever":       fileInfo{110, 2},
+		"2222222222222222222222222222222222222222222222222222222222222222/repodata/a":     fileInfo{1000, 2},
+		"2222222222222222222222222222222222222222222222222222222222222222/repodata/b":     fileInfo{3829, 2},
+		"2222222222222222222222222222222222222222222222222222222222222222/repodata/c":     fileInfo{831989, 2},
+		"3333333333333333333333333333333333333333333333333333333333333333.solv":           fileInfo{105, 4},
+		"3333333333333333333333333333333333333333333333333333333333333333-filenames.solv": fileInfo{200, 4},
+		"3333333333333333333333333333333333333333333333333333333333333333.whatever":       fileInfo{110, 4},
+		"3333333333333333333333333333333333333333333333333333333333333333/repodata/a":     fileInfo{2390, 4},
+		"3333333333333333333333333333333333333333333333333333333333333333/repodata/b":     fileInfo{1234890, 4},
+		"3333333333333333333333333333333333333333333333333333333333333333/repodata/c":     fileInfo{483, 4},
+	},
+	"completely-fake": { // just a mess of files (including files without a repo ID)
+		"somefile": fileInfo{192, 10291920},
+		"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy-repofiley":  fileInfo{29384, 11},
+		"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy-repofiley2": fileInfo{293, 31},
+		"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-repofile":   fileInfo{29384, 30},
+		"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-repofileb":  fileInfo{293, 45},
+	},
+}
+
+type testCase struct {
+	cache              testCache
+	maxSize            uint64
+	minSizeAfterShrink uint64
+	repoIDsAfterShrink []string
+}
+
+func getRepoIDs(ct testCache) []string {
+	idMap := make(map[string]bool)
+	ids := make([]string, 0)
+	for path := range ct {
+		if len(path) >= 64 {
+			id := path[:64]
+			if !idMap[id] {
+				idMap[id] = true
+				ids = append(ids, id)
+			}
+		}
+	}
+	return ids
+}
+
+func TestCacheRead(t *testing.T) {
+	assert := assert.New(t)
+	for name, cfg := range testCfgs {
+		t.Run(name, func(t *testing.T) {
+			testCacheRoot := t.TempDir()
+			s := createTestCache(testCacheRoot, cfg)
+
+			cache := newRPMCache(testCacheRoot, 1048576) // 1 MiB, but doesn't matter for this test
+
+			nrepos := len(getRepoIDs(cfg))
+			assert.Equal(s, cache.size)
+			assert.Equal(nrepos, len(cache.repoElements))
+			assert.Equal(nrepos, len(cache.repoRecency))
+		})
+	}
+}
+
+func strSliceContains(slc []string, elem string) bool {
+	for _, s := range slc {
+		if elem == s {
+			return true
+		}
+	}
+	return false
+}
+
+func sizeSum(cfg testCache, repoIDFilter ...string) uint64 {
+	var sum uint64
+	for path, info := range cfg {
+		if len(path) < 64 {
+			continue
+		}
+		rid := path[:64]
+		if len(repoIDFilter) == 0 || (len(repoIDFilter) > 0 && strSliceContains(repoIDFilter, rid)) {
+			sum += info.size
+		}
+	}
+	return sum
+}
+
+func TestCacheCleanup(t *testing.T) {
+	rhelRecentRepoSize := sizeSum(testCfgs["rhel84-aarch64"], "df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62")
+	rhelTotalRepoSize := sizeSum(testCfgs["rhel84-aarch64"])
+
+	fakeRealSize2 := sizeSum(testCfgs["fake-real"], "2222222222222222222222222222222222222222222222222222222222222222")
+	fakeRealSize3 := sizeSum(testCfgs["fake-real"], "3333333333333333333333333333333333333333333333333333333333333333")
+
+	testCases := map[string]testCase{
+		// max size 1 byte -> clean will delete everything
+		"fake-real-full-delete": {
+			cache:              testCfgs["fake-real"],
+			maxSize:            1,
+			minSizeAfterShrink: 0,
+		},
+		"rhel-full-delete": {
+			cache:              testCfgs["rhel84-aarch64"],
+			maxSize:            1,
+			minSizeAfterShrink: 0,
+		},
+		"completely-fake-full-delete": {
+			cache:              testCfgs["completely-fake"],
+			maxSize:            1,
+			minSizeAfterShrink: 0,
+		},
+		"completely-fake-full-delete-2": {
+			cache:              testCfgs["completely-fake"],
+			maxSize:            100,
+			minSizeAfterShrink: 0,
+		},
+		// max size a bit larger than most recent repo -> clean will delete older repos
+		"completely-fake-half-delete": {
+			cache:              testCfgs["completely-fake"],
+			maxSize:            29384 + 293 + 1,                                                              // one byte larger than the files of one repo
+			minSizeAfterShrink: 29384 + 293,                                                                  // size of files from one repo
+			repoIDsAfterShrink: []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, // most recent repo timestamp (45)
+		},
+		"rhel-half-delete": {
+			cache:              testCfgs["rhel84-aarch64"],
+			maxSize:            rhelRecentRepoSize + 102400,                                                  // most recent repo file sizes + 100k buffer (for directories)
+			minSizeAfterShrink: rhelRecentRepoSize,                                                           // after shrink it should be at least as big as the most recent repo
+			repoIDsAfterShrink: []string{"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62"}, // most recent repo timestamp (45)
+		},
+		"fake-real-delete-1": {
+			cache:              testCfgs["fake-real"],
+			maxSize:            fakeRealSize3 + fakeRealSize2 + 102400,
+			minSizeAfterShrink: fakeRealSize3 + fakeRealSize2,
+			repoIDsAfterShrink: []string{"3333333333333333333333333333333333333333333333333333333333333333", "2222222222222222222222222222222222222222222222222222222222222222"},
+		},
+		"fake-real-delete-2": {
+			cache:              testCfgs["fake-real"],
+			maxSize:            fakeRealSize3 + 102400,
+			minSizeAfterShrink: fakeRealSize3,
+			repoIDsAfterShrink: []string{"3333333333333333333333333333333333333333333333333333333333333333"},
+		},
+		// max size is huge -> clean wont delete anything
+		"rhel-no-delete": {
+			cache:              testCfgs["rhel84-aarch64"],
+			maxSize:            45097156608, // 42 GiB
+			minSizeAfterShrink: rhelTotalRepoSize,
+			repoIDsAfterShrink: []string{"df2665154150abf76f4d86156228a75c39f3f31a79d4a861d76b1edd89814b62", "9adf133053f0691a0ec12e73cbf1875a90c9268b4f09162fc3387fd76ecb3bcc"},
+		},
+	}
+
+	for name, cfg := range testCases {
+		t.Run(name, func(t *testing.T) {
+			assert := assert.New(t)
+			testCacheRoot := t.TempDir()
+			createTestCache(testCacheRoot, cfg.cache)
+			cache := newRPMCache(testCacheRoot, cfg.maxSize)
+			err := cache.shrink()
+			assert.NoError(err)
+
+			// it's hard to predict the exact size after shrink because of directory sizes
+			// so let's just check that the new size is between min and max
+			assert.LessOrEqual(cfg.minSizeAfterShrink, cache.size)
+			assert.Greater(cfg.maxSize, cache.size)
+			assert.Equal(len(cfg.repoIDsAfterShrink), len(cache.repoElements))
+			for _, id := range cfg.repoIDsAfterShrink {
+				assert.Contains(cache.repoElements, id)
+			}
+		})
+	}
+}
diff --git a/internal/kojiapi/server_test.go b/internal/kojiapi/server_test.go
index 75c98c615..2dda92012 100644
--- a/internal/kojiapi/server_test.go
+++ b/internal/kojiapi/server_test.go
@@ -352,9 +352,9 @@ func TestCompose(t *testing.T) {
 		wg.Add(1)
 
 		go func(t *testing.T, result worker.KojiInitJobResult) {
-			_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-init"}, []string{""})
+			_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{worker.JobTypeKojiInit}, []string{""})
 			require.NoError(t, err)
-			require.Equal(t, "koji-init", jobType)
+			require.Equal(t, worker.JobTypeKojiInit, jobType)
 
 			var initJob worker.KojiInitJob
 			err = json.Unmarshal(rawJob, &initJob)
@@ -405,9 +405,9 @@ func TestCompose(t *testing.T) {
 			c.composeReplyCode, c.composeReply, "id")
 		wg.Wait()
 
-		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"}, []string{""})
+		_, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{worker.JobTypeOSBuildKoji}, []string{""})
 		require.NoError(t, err)
-		require.Equal(t, "osbuild-koji", jobType)
+		require.Equal(t, worker.JobTypeOSBuildKoji, jobType)
 
 		var osbuildJob worker.OSBuildKojiJob
 		err = json.Unmarshal(rawJob, &osbuildJob)
@@ -421,9 +421,9 @@ func TestCompose(t *testing.T) {
 		test.TestRoute(t, workerHandler, false, "PATCH", fmt.Sprintf("/api/worker/v1/jobs/%v", token), string(buildJobResult), http.StatusOK,
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
-		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"osbuild-koji"}, []string{""})
+		_, token, jobType, rawJob, _, err = workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{worker.JobTypeOSBuildKoji}, []string{""})
 		require.NoError(t, err)
-		require.Equal(t, "osbuild-koji", jobType)
+		require.Equal(t, worker.JobTypeOSBuildKoji, jobType)
 
 		err = json.Unmarshal(rawJob, &osbuildJob)
 		require.NoError(t, err)
@@ -444,9 +444,9 @@ func TestCompose(t *testing.T) {
 		}`, test_distro.TestArchName, test_distro.TestDistroName), http.StatusOK,
 			fmt.Sprintf(`{"href":"/api/worker/v1/jobs/%v","id":"%v","kind":"UpdateJobResponse"}`, token, token))
 
-		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{"koji-finalize"}, []string{""})
+		finalizeID, token, jobType, rawJob, _, err := workerServer.RequestJob(context.Background(), test_distro.TestArchName, []string{worker.JobTypeKojiFinalize}, []string{""})
 		require.NoError(t, err)
-		require.Equal(t, "koji-finalize", jobType)
+		require.Equal(t, worker.JobTypeKojiFinalize, jobType)
 
 		var kojiFinalizeJob worker.KojiFinalizeJob
 		err = json.Unmarshal(rawJob, &kojiFinalizeJob)
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
index 4e0fff8a7..38910c60e 100644
--- a/internal/test/helpers.go
+++ b/internal/test/helpers.go
@@ -75,16 +75,8 @@ func dropFields(obj interface{}, fields ...string) {
 	switch v := obj.(type) {
 	// if the interface type is a map attempt to delete the fields
 	case map[string]interface{}:
-		for i, field := range fields {
-			if _, ok := v[field]; ok {
-				delete(v, field)
-				// if the field is found remove it from the fields slice
-				if len(fields) < i-1 {
-					fields = append(fields[:i], fields[i+1:]...)
-				} else {
-					fields = fields[:i]
-				}
-			}
+		for _, field := range fields {
+			delete(v, field)
 		}
 		// call dropFields on the remaining elements since they may contain a map containing the field
 		for _, val := range v {
@@ -102,13 +94,20 @@ func dropFields(obj interface{}, fields ...string) {
 
 func TestRoute(t *testing.T, api http.Handler, external bool, method, path, body string, expectedStatus int, expectedJSON string, ignoreFields ...string) {
 	t.Helper()
+	_ = TestRouteWithReply(t, api, external, method, path, body, expectedStatus, expectedJSON, ignoreFields...)
+}
+
+// TestRouteWithReply tests the given API endpoint and if the test passes, it returns the raw JSON reply.
+func TestRouteWithReply(t *testing.T, api http.Handler, external bool, method, path, body string, expectedStatus int, expectedJSON string, ignoreFields ...string) (replyJSON []byte) {
+	t.Helper()
 
 	resp := SendHTTP(api, external, method, path, body)
 	if resp == nil {
 		t.Skip("This test is for internal testing only")
 	}
 
-	replyJSON, err := ioutil.ReadAll(resp.Body)
+	var err error
+	replyJSON, err = ioutil.ReadAll(resp.Body)
 	require.NoErrorf(t, err, "%s: could not read response body", path)
 
 	assert.Equalf(t, expectedStatus, resp.StatusCode, "SendHTTP failed for path %s: %v", path, string(replyJSON))
@@ -136,6 +135,8 @@ func TestRoute(t *testing.T, api http.Handler, external bool, method, path, body
 	dropFields(expected, ignoreFields...)
 
 	require.Equal(t, expected, reply)
+
+	return
 }
 
 func TestTOMLRoute(t *testing.T, api http.Handler, external bool, method, path, body string, expectedStatus int, expectedTOML string, ignoreFields ...string) {
diff --git a/internal/weldr/compose_test.go b/internal/weldr/compose_test.go
index a04f27039..a2c2bf5ce 100644
--- a/internal/weldr/compose_test.go
+++ b/internal/weldr/compose_test.go
@@ -40,7 +40,7 @@ func TestComposeStatusFromLegacyError(t *testing.T) {
 	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 
@@ -82,7 +82,7 @@ func TestComposeStatusFromJobError(t *testing.T) {
 	jobId, err := api.workers.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, _, _, _, err := api.workers.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 
diff --git a/internal/worker/client_test.go b/internal/worker/client_test.go
index 0f6e20c05..93a9b6b8f 100644
--- a/internal/worker/client_test.go
+++ b/internal/worker/client_test.go
@@ -92,7 +92,7 @@ func TestOAuth(t *testing.T) {
 		BasePath:     "/api/image-builder-worker/v1",
 	})
 	require.NoError(t, err)
-	job, err := client.RequestJob([]string{"osbuild"}, "arch")
+	job, err := client.RequestJob([]string{worker.JobTypeOSBuild}, "arch")
 	require.NoError(t, err)
 	r := strings.NewReader("artifact contents")
 	require.NoError(t, job.UploadArtifact("some-artifact", r))
@@ -120,7 +120,7 @@ func TestProxy(t *testing.T) {
 	})
 
 	require.NoError(t, err)
-	job, err := client.RequestJob([]string{"osbuild"}, "arch")
+	job, err := client.RequestJob([]string{worker.JobTypeOSBuild}, "arch")
 	require.NoError(t, err)
 	r := strings.NewReader("artifact contents")
 	require.NoError(t, job.UploadArtifact("some-artifact", r))
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index f707f0b8d..f692bb7a6 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -124,8 +124,8 @@ func TestCreate(t *testing.T) {
 	require.NoError(t, err)
 
 	test.TestRoute(t, handler, false, "POST", "/api/worker/v1/jobs",
-		fmt.Sprintf(`{"types":["osbuild"],"arch":"%s"}`, test_distro.TestArchName), http.StatusCreated,
-		`{"kind":"RequestJob","href":"/api/worker/v1/jobs","type":"osbuild","args":{"manifest":{"pipeline":{},"sources":{}}}}`, "id", "location", "artifact_location")
+		fmt.Sprintf(`{"types":["%s"],"arch":"%s"}`, worker.JobTypeOSBuild, test_distro.TestArchName), http.StatusCreated,
+		fmt.Sprintf(`{"kind":"RequestJob","href":"/api/worker/v1/jobs","type":"%s","args":{"manifest":{"pipeline":{},"sources":{}}}}`, worker.JobTypeOSBuild), "id", "location", "artifact_location")
 }
 
 func TestCancel(t *testing.T) {
@@ -148,10 +148,10 @@ func TestCancel(t *testing.T) {
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
-	require.Equal(t, "osbuild", typ)
+	require.Equal(t, worker.JobTypeOSBuild, typ)
 	require.NotNil(t, args)
 	require.Nil(t, dynamicArgs)
 
@@ -185,10 +185,10 @@ func TestUpdate(t *testing.T) {
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
-	require.Equal(t, "osbuild", typ)
+	require.Equal(t, worker.JobTypeOSBuild, typ)
 	require.NotNil(t, args)
 	require.Nil(t, dynamicArgs)
 
@@ -221,7 +221,7 @@ func TestArgs(t *testing.T) {
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &job, "")
 	require.NoError(t, err)
 
-	_, _, _, args, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	_, _, _, args, _, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.NotNil(t, args)
 
@@ -251,10 +251,10 @@ func TestUpload(t *testing.T) {
 	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobID, j)
-	require.Equal(t, "osbuild", typ)
+	require.Equal(t, worker.JobTypeOSBuild, typ)
 	require.NotNil(t, args)
 	require.Nil(t, dynamicArgs)
 
@@ -281,10 +281,10 @@ func TestUploadAlteredBasePath(t *testing.T) {
 	jobID, err := server.EnqueueOSBuild(arch.Name(), &worker.OSBuildJob{Manifest: manifest}, "")
 	require.NoError(t, err)
 
-	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	j, token, typ, args, dynamicArgs, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.NoError(t, err)
 	require.Equal(t, jobID, j)
-	require.Equal(t, "osbuild", typ)
+	require.Equal(t, worker.JobTypeOSBuild, typ)
 	require.NotNil(t, args)
 	require.Nil(t, dynamicArgs)
 
@@ -299,7 +299,7 @@ func TestTimeout(t *testing.T) {
 	}
 	server := newTestServer(t, t.TempDir(), time.Millisecond*10, "/api/image-builder-worker/v1")
 
-	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"osbuild"}, []string{""})
+	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeOSBuild}, []string{""})
 	require.Equal(t, jobqueue.ErrDequeueTimeout, err)
 
 	test.TestRoute(t, server.Handler(), false, "POST", "/api/image-builder-worker/v1/jobs", `{"arch":"arch","types":["types"]}`, http.StatusNoContent,
@@ -321,13 +321,13 @@ func TestRequestJobById(t *testing.T) {
 	jobId, err := server.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobId, "")
 	require.NoError(t, err)
 
-	test.TestRoute(t, server.Handler(), false, "POST", "/api/worker/v1/jobs", `{"arch":"arch","types":["manifest-id-only"]}`, http.StatusBadRequest,
+	test.TestRoute(t, server.Handler(), false, "POST", "/api/worker/v1/jobs", fmt.Sprintf(`{"arch":"arch","types":["%s"]}`, worker.JobTypeManifestIDOnly), http.StatusBadRequest,
 		`{"href":"/api/worker/v1/errors/15","kind":"Error","id": "15","code":"IMAGE-BUILDER-WORKER-15"}`, "operation_id", "reason", "message")
 
 	_, _, _, _, _, err = server.RequestJobById(context.Background(), arch.Name(), jobId)
 	require.Error(t, jobqueue.ErrNotPending, err)
 
-	_, token, _, _, _, err := server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"}, []string{""})
+	_, token, _, _, _, err := server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeDepsolve}, []string{""})
 	require.NoError(t, err)
 
 	depsolveJR, err := json.Marshal(worker.DepsolveJobResult{})
@@ -338,7 +338,7 @@ func TestRequestJobById(t *testing.T) {
 	j, token, typ, args, dynamicArgs, err := server.RequestJobById(context.Background(), arch.Name(), jobId)
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
-	require.Equal(t, "manifest-id-only", typ)
+	require.Equal(t, worker.JobTypeManifestIDOnly, typ)
 	require.NotNil(t, args)
 	require.NotNil(t, dynamicArgs)
 
@@ -399,7 +399,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"}, []string{""})
+		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{worker.JobTypeOSBuild}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -544,7 +544,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	for idx := uint(0); idx < 2; idx++ {
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"}, []string{""})
+		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{worker.JobTypeKojiInit}, []string{""})
 		require.NoError(err)
 		require.NoError(server.FinishJob(token, nil))
 	}
@@ -553,7 +553,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"}, []string{""})
+		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{worker.JobTypeOSBuildKoji}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -653,7 +653,7 @@ func TestDepsolveLegacyErrorConversion(t *testing.T) {
 	depsolveJobId, err := server.EnqueueDepsolve(&worker.DepsolveJob{}, "")
 	require.NoError(t, err)
 
-	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{"depsolve"}, []string{""})
+	_, _, _, _, _, err = server.RequestJob(context.Background(), arch.Name(), []string{worker.JobTypeDepsolve}, []string{""})
 	require.NoError(t, err)
 
 	reason := "Depsolve failed"
@@ -722,7 +722,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{"osbuild"}, []string{""})
+		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{worker.JobTypeOSBuild}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -832,7 +832,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 	for idx := uint(0); idx < 2; idx++ {
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"koji-init"}, []string{""})
+		_, token, _, _, _, err := server.RequestJob(ctx, "k", []string{worker.JobTypeKojiInit}, []string{""})
 		require.NoError(err)
 		require.NoError(server.FinishJob(token, nil))
 	}
@@ -841,7 +841,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 		// don't block forever if the jobs weren't added or can't be retrieved
 		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
 		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{"osbuild-koji"}, []string{""})
+		id, token, _, _, _, err := server.RequestJob(ctx, "k", []string{worker.JobTypeOSBuildKoji}, []string{""})
 		require.NoError(err)
 		return id, token
 	}
@@ -1014,3 +1014,705 @@ func TestDepsolveJobArgsCompat(t *testing.T) {
 		assert.Equal(newJob, newJobW)
 	}
 }
+
+type testJob struct {
+	main   interface{}
+	deps   []testJob
+	result interface{}
+}
+
+func enqueueAndFinishTestJobDependencies(s *worker.Server, deps []testJob) ([]uuid.UUID, error) {
+	ids := []uuid.UUID{}
+
+	for _, dep := range deps {
+		var depUUIDs []uuid.UUID
+		var err error
+		if len(dep.deps) > 0 {
+			depUUIDs, err = enqueueAndFinishTestJobDependencies(s, dep.deps)
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		var id uuid.UUID
+		switch dep.main.(type) {
+		case *worker.OSBuildJob:
+			job := dep.main.(*worker.OSBuildJob)
+			id, err = s.EnqueueOSBuildAsDependency(distro.X86_64ArchName, job, depUUIDs, "")
+			if err != nil {
+				return nil, err
+			}
+
+		case *worker.ManifestJobByID:
+			job := dep.main.(*worker.ManifestJobByID)
+			if len(depUUIDs) != 1 {
+				return nil, fmt.Errorf("exactly one dependency is expected for ManifestJobByID, got: %d", len(depUUIDs))
+			}
+			id, err = s.EnqueueManifestJobByID(job, depUUIDs[0], "")
+			if err != nil {
+				return nil, err
+			}
+
+		case *worker.DepsolveJob:
+			job := dep.main.(*worker.DepsolveJob)
+			if len(depUUIDs) != 0 {
+				return nil, fmt.Errorf("dependencies are not supported for DepsolveJob, got: %d", len(depUUIDs))
+			}
+			id, err = s.EnqueueDepsolve(job, "")
+			if err != nil {
+				return nil, err
+			}
+
+		case *worker.KojiInitJob:
+			job := dep.main.(*worker.KojiInitJob)
+			if len(depUUIDs) != 0 {
+				return nil, fmt.Errorf("dependencies are not supported for KojiInitJob, got: %d", len(depUUIDs))
+			}
+			id, err = s.EnqueueKojiInit(job, "")
+			if err != nil {
+				return nil, err
+			}
+
+		case *worker.OSBuildKojiJob:
+			job := dep.main.(*worker.OSBuildKojiJob)
+			if len(depUUIDs) != 2 {
+				return nil, fmt.Errorf("exactly two dependency is expected for OSBuildKojiJob, got: %d", len(depUUIDs))
+			}
+			id, err = s.EnqueueOSBuildKojiAsDependency(distro.X86_64ArchName, job, depUUIDs[1], depUUIDs[0], "")
+			if err != nil {
+				return nil, err
+			}
+
+		case *worker.KojiFinalizeJob:
+			job := dep.main.(*worker.KojiFinalizeJob)
+			if len(depUUIDs) < 2 {
+				return nil, fmt.Errorf("at least two dependencies are expected for KojiFinalizeJob, got: %d", len(depUUIDs))
+			}
+			id, err = s.EnqueueKojiFinalize(job, depUUIDs[0], depUUIDs[1:], "")
+			if err != nil {
+				return nil, err
+			}
+
+		default:
+			return nil, fmt.Errorf("unexpected job type")
+		}
+
+		// request the previously added Job
+		_, token, _, _, _, err := s.RequestJobById(context.Background(), distro.X86_64ArchName, id)
+		if err != nil {
+			return nil, err
+		}
+		result, err := json.Marshal(dep.result)
+		if err != nil {
+			return nil, err
+		}
+		// mark the job as finished using the defined job result
+		err = s.FinishJob(token, result)
+		if err != nil {
+			return nil, err
+		}
+		ids = append(ids, id)
+	}
+	return ids, nil
+}
+
+func TestJobDependencyChainErrors(t *testing.T) {
+	var cases = []struct {
+		job           testJob
+		expectedError *clienterrors.Error
+	}{
+		// osbuild + manifest + depsolve
+		// failed depsolve
+		{
+			job: testJob{
+				main: &worker.OSBuildJob{},
+				deps: []testJob{
+					{
+						main: &worker.ManifestJobByID{},
+						deps: []testJob{
+							{
+								main: &worker.DepsolveJob{},
+								result: &worker.DepsolveJobResult{
+									JobResult: worker.JobResult{
+										JobError: &clienterrors.Error{
+											ID:     clienterrors.ErrorDNFDepsolveError,
+											Reason: "package X not found",
+										},
+									},
+								},
+							},
+						},
+						result: &worker.ManifestJobByIDResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorDepsolveDependency,
+									Reason: "depsolve dependency job failed",
+								},
+							},
+						},
+					},
+				},
+				result: &worker.OSBuildJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorManifestDependency,
+							Reason: "manifest dependency job failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorManifestDependency,
+				Reason: "manifest dependency job failed",
+				Details: []*clienterrors.Error{
+					{
+						ID:     clienterrors.ErrorDepsolveDependency,
+						Reason: "depsolve dependency job failed",
+						Details: []*clienterrors.Error{
+							{
+								ID:     clienterrors.ErrorDNFDepsolveError,
+								Reason: "package X not found",
+							},
+						},
+					},
+				},
+			},
+		},
+		// osbuild + manifest + depsolve
+		// failed manifest
+		{
+			job: testJob{
+				main: &worker.OSBuildJob{},
+				deps: []testJob{
+					{
+						main: &worker.ManifestJobByID{},
+						deps: []testJob{
+							{
+								main:   &worker.DepsolveJob{},
+								result: &worker.DepsolveJobResult{},
+							},
+						},
+						result: &worker.ManifestJobByIDResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorManifestGeneration,
+									Reason: "failed to generate manifest",
+								},
+							},
+						},
+					},
+				},
+				result: &worker.OSBuildJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorManifestDependency,
+							Reason: "manifest dependency job failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorManifestDependency,
+				Reason: "manifest dependency job failed",
+				Details: []*clienterrors.Error{
+					{
+						ID:     clienterrors.ErrorManifestGeneration,
+						Reason: "failed to generate manifest",
+					},
+				},
+			},
+		},
+		// osbuild + manifest + depsolve
+		// failed osbuild
+		{
+			job: testJob{
+				main: &worker.OSBuildJob{},
+				deps: []testJob{
+					{
+						main: &worker.ManifestJobByID{},
+						deps: []testJob{
+							{
+								main:   &worker.DepsolveJob{},
+								result: &worker.DepsolveJobResult{},
+							},
+						},
+						result: &worker.ManifestJobByIDResult{
+							JobResult: worker.JobResult{},
+						},
+					},
+				},
+				result: &worker.OSBuildJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorEmptyManifest,
+							Reason: "empty manifest received",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorEmptyManifest,
+				Reason: "empty manifest received",
+			},
+		},
+		// koji-init + osbuild-koji + manifest + depsolve
+		// failed depsolve
+		{
+			job: testJob{
+				main: &worker.OSBuildKojiJob{},
+				deps: []testJob{
+					{
+						main:   &worker.KojiInitJob{},
+						result: &worker.KojiInitJobResult{},
+					},
+					{
+						main: &worker.ManifestJobByID{},
+						deps: []testJob{
+							{
+								main: &worker.DepsolveJob{},
+								result: &worker.DepsolveJobResult{
+									JobResult: worker.JobResult{
+										JobError: &clienterrors.Error{
+											ID:     clienterrors.ErrorDNFDepsolveError,
+											Reason: "package X not found",
+										},
+									},
+								},
+							},
+						},
+						result: &worker.ManifestJobByIDResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorDepsolveDependency,
+									Reason: "depsolve dependency job failed",
+								},
+							},
+						},
+					},
+				},
+				result: &worker.OSBuildKojiJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorManifestDependency,
+							Reason: "manifest dependency job failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorManifestDependency,
+				Reason: "manifest dependency job failed",
+				Details: []*clienterrors.Error{
+					{
+						ID:     clienterrors.ErrorDepsolveDependency,
+						Reason: "depsolve dependency job failed",
+						Details: []*clienterrors.Error{
+							{
+								ID:     clienterrors.ErrorDNFDepsolveError,
+								Reason: "package X not found",
+							},
+						},
+					},
+				},
+			},
+		},
+		// koji-init + (osbuild-koji + manifest + depsolve) + (osbuild-koji + manifest + depsolve) + koji-finalize
+		// failed one depsolve
+		{
+			job: testJob{
+				main: &worker.KojiFinalizeJob{},
+				deps: []testJob{
+					{
+						main:   &worker.KojiInitJob{},
+						result: &worker.KojiInitJobResult{},
+					},
+					// failed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main: &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{
+											JobResult: worker.JobResult{
+												JobError: &clienterrors.Error{
+													ID:     clienterrors.ErrorDNFDepsolveError,
+													Reason: "package X not found",
+												},
+											},
+										},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{
+									JobResult: worker.JobResult{
+										JobError: &clienterrors.Error{
+											ID:     clienterrors.ErrorDepsolveDependency,
+											Reason: "depsolve dependency job failed",
+										},
+									},
+								},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorManifestDependency,
+									Reason: "manifest dependency job failed",
+								},
+							},
+						},
+					},
+					// passed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main:   &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							OSBuildOutput: &osbuild2.Result{},
+						},
+					},
+				},
+				result: &worker.KojiFinalizeJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorKojiFailedDependency,
+							Reason: "one build failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorKojiFailedDependency,
+				Reason: "one build failed",
+				Details: []*clienterrors.Error{
+					{
+						ID:     clienterrors.ErrorManifestDependency,
+						Reason: "manifest dependency job failed",
+						Details: []*clienterrors.Error{
+							{
+								ID:     clienterrors.ErrorDepsolveDependency,
+								Reason: "depsolve dependency job failed",
+								Details: []*clienterrors.Error{
+									{
+										ID:     clienterrors.ErrorDNFDepsolveError,
+										Reason: "package X not found",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		// koji-init + (osbuild-koji + manifest + depsolve) + (osbuild-koji + manifest + depsolve) + koji-finalize
+		// failed both depsolve
+		{
+			job: testJob{
+				main: &worker.KojiFinalizeJob{},
+				deps: []testJob{
+					{
+						main:   &worker.KojiInitJob{},
+						result: &worker.KojiInitJobResult{},
+					},
+					// failed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main: &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{
+											JobResult: worker.JobResult{
+												JobError: &clienterrors.Error{
+													ID:     clienterrors.ErrorDNFDepsolveError,
+													Reason: "package X not found",
+												},
+											},
+										},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{
+									JobResult: worker.JobResult{
+										JobError: &clienterrors.Error{
+											ID:     clienterrors.ErrorDepsolveDependency,
+											Reason: "depsolve dependency job failed",
+										},
+									},
+								},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorManifestDependency,
+									Reason: "manifest dependency job failed",
+								},
+							},
+						},
+					},
+					// failed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main: &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{
+											JobResult: worker.JobResult{
+												JobError: &clienterrors.Error{
+													ID:     clienterrors.ErrorDNFDepsolveError,
+													Reason: "package Y not found",
+												},
+											},
+										},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{
+									JobResult: worker.JobResult{
+										JobError: &clienterrors.Error{
+											ID:     clienterrors.ErrorDepsolveDependency,
+											Reason: "depsolve dependency job failed",
+										},
+									},
+								},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							JobResult: worker.JobResult{
+								JobError: &clienterrors.Error{
+									ID:     clienterrors.ErrorManifestDependency,
+									Reason: "manifest dependency job failed",
+								},
+							},
+						},
+					},
+				},
+				result: &worker.KojiFinalizeJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorKojiFailedDependency,
+							Reason: "two builds failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorKojiFailedDependency,
+				Reason: "two builds failed",
+				Details: []*clienterrors.Error{
+					{
+						ID:     clienterrors.ErrorManifestDependency,
+						Reason: "manifest dependency job failed",
+						Details: []*clienterrors.Error{
+							{
+								ID:     clienterrors.ErrorDepsolveDependency,
+								Reason: "depsolve dependency job failed",
+								Details: []*clienterrors.Error{
+									{
+										ID:     clienterrors.ErrorDNFDepsolveError,
+										Reason: "package X not found",
+									},
+								},
+							},
+						},
+					},
+					{
+						ID:     clienterrors.ErrorManifestDependency,
+						Reason: "manifest dependency job failed",
+						Details: []*clienterrors.Error{
+							{
+								ID:     clienterrors.ErrorDepsolveDependency,
+								Reason: "depsolve dependency job failed",
+								Details: []*clienterrors.Error{
+									{
+										ID:     clienterrors.ErrorDNFDepsolveError,
+										Reason: "package Y not found",
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		// koji-init + (osbuild-koji + manifest + depsolve) + (osbuild-koji + manifest + depsolve) + koji-finalize
+		// failed koji-finalize
+		{
+			job: testJob{
+				main: &worker.KojiFinalizeJob{},
+				deps: []testJob{
+					{
+						main:   &worker.KojiInitJob{},
+						result: &worker.KojiInitJobResult{},
+					},
+					// passed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main:   &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							OSBuildOutput: &osbuild2.Result{},
+						},
+					},
+					// passed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main:   &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							OSBuildOutput: &osbuild2.Result{},
+						},
+					},
+				},
+				result: &worker.KojiFinalizeJobResult{
+					JobResult: worker.JobResult{
+						JobError: &clienterrors.Error{
+							ID:     clienterrors.ErrorKojiFinalize,
+							Reason: "koji-finalize failed",
+						},
+					},
+				},
+			},
+			expectedError: &clienterrors.Error{
+				ID:     clienterrors.ErrorKojiFinalize,
+				Reason: "koji-finalize failed",
+			},
+		},
+		// koji-init + (osbuild-koji + manifest + depsolve) + (osbuild-koji + manifest + depsolve) + koji-finalize
+		// all passed
+		{
+			job: testJob{
+				main: &worker.KojiFinalizeJob{},
+				deps: []testJob{
+					{
+						main:   &worker.KojiInitJob{},
+						result: &worker.KojiInitJobResult{},
+					},
+					// passed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main:   &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							OSBuildOutput: &osbuild2.Result{},
+						},
+					},
+					// passed build
+					{
+						main: &worker.OSBuildKojiJob{},
+						deps: []testJob{
+							{
+								main:   &worker.KojiInitJob{},
+								result: &worker.KojiInitJobResult{},
+							},
+							{
+								main: &worker.ManifestJobByID{},
+								deps: []testJob{
+									{
+										main:   &worker.DepsolveJob{},
+										result: &worker.DepsolveJobResult{},
+									},
+								},
+								result: &worker.ManifestJobByIDResult{},
+							},
+						},
+						result: &worker.OSBuildKojiJobResult{
+							OSBuildOutput: &osbuild2.Result{},
+						},
+					},
+				},
+				result: &worker.KojiFinalizeJobResult{
+					JobResult: worker.JobResult{},
+				},
+			},
+			expectedError: nil,
+		},
+	}
+
+	for idx, c := range cases {
+		t.Logf("Test case #%d", idx)
+		server := newTestServer(t, t.TempDir(), time.Duration(0), "/api/worker/v1")
+		ids, err := enqueueAndFinishTestJobDependencies(server, []testJob{c.job})
+		require.Nil(t, err)
+		require.Len(t, ids, 1)
+
+		mainJobID := ids[0]
+		errors, err := server.JobDependencyChainErrors(mainJobID)
+		require.Nil(t, err)
+		assert.EqualValues(t, c.expectedError, errors)
+	}
+}
diff --git a/test/cases/api.sh b/test/cases/api.sh
index 5102a0e60..c24dedb24 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -1666,8 +1666,9 @@ jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
 jwt_tenant_provider_fields = ["rh-org-id"]
 EOF
 
+REFRESH_TOKEN="offlineToken"
 cat <<EOF | sudo tee "/etc/osbuild-worker/token"
-offlineToken
+$REFRESH_TOKEN
 EOF
 
 cat <<EOF | sudo tee "/etc/osbuild-worker/osbuild-worker.toml"
@@ -1689,7 +1690,14 @@ sudo systemctl restart osbuild-composer
 until curl --output /dev/null --silent --fail localhost:8081/token; do
     sleep 0.5
 done
-TOKEN="$(curl localhost:8081/token | jq -r .access_token)"
+
+TOKEN="$(curl --request POST \
+        --data "refresh_token=$REFRESH_TOKEN" \
+        --header "Content-Type: application/x-www-form-urlencoded" \
+        --silent \
+        --show-error \
+        --fail \
+        localhost:8081/token | jq -r .access_token)"
 
 [ "$(curl \
         --silent \
@@ -1707,7 +1715,7 @@ TOKEN="$(curl localhost:8081/token | jq -r .access_token)"
         http://localhost:443/api/image-builder-composer/v2/openapi)" = "200" ]
 
 
-# /composes/$ID does doesn't need auth
+# /composes/$ID does need auth
 [ "$(curl \
         --silent \
         --output /dev/null \
diff --git a/test/data/manifests/rhel_85-x86_64-azure_rhui-boot.json b/test/data/manifests/rhel_85-x86_64-azure_rhui-boot.json
index 9b67a227d..3def7bd85 100644
--- a/test/data/manifests/rhel_85-x86_64-azure_rhui-boot.json
+++ b/test/data/manifests/rhel_85-x86_64-azure_rhui-boot.json
@@ -16,7 +16,7 @@
       },
       {
         "name": "rhui-azure",
-        "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220227",
+        "baseurl": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220504",
         "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"
@@ -2829,15 +2829,23 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-nouveau.conf",
+              "filename": "blacklist-amdgpu.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "nouveau"
-                },
+                  "modulename": "amdgpu"
+                }
+              ]
+            }
+          },
+          {
+            "type": "org.osbuild.modprobe",
+            "options": {
+              "filename": "blacklist-intel-cstate.conf",
+              "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "lbm-nouveau"
+                  "modulename": "intel_cstate"
                 }
               ]
             }
@@ -2857,11 +2865,15 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-skylake-edac.conf",
+              "filename": "blacklist-nouveau.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "skx_edac"
+                  "modulename": "nouveau"
+                },
+                {
+                  "command": "blacklist",
+                  "modulename": "lbm-nouveau"
                 }
               ]
             }
@@ -2869,11 +2881,11 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-intel-cstate.conf",
+              "filename": "blacklist-skylake-edac.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "intel_cstate"
+                  "modulename": "skx_edac"
                 }
               ]
             }
@@ -3614,7 +3626,7 @@
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-baseos-r8.5-20220504/Packages/gobject-introspection-1.56.1-1.el8.x86_64.rpm"
           },
           "sha256:10e429e047cea577ade1d52422a39af6349b4427b6aba8865dc4eba169c142ce": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220227/rhui-azure-rhel8-2.2-198.noarch.rpm"
+            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220504/rhui-azure-rhel8-2.2-198.noarch.rpm"
           },
           "sha256:1136323a37c5940055c3891a260b26782b75ea986fb5fe02ad80364f60527450": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-baseos-r8.5-20220504/Packages/grub2-common-2.02-106.el8.noarch.rpm"
@@ -13338,7 +13350,7 @@
         "version": "2.2",
         "release": "198",
         "arch": "noarch",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220227/rhui-azure-rhel8-2.2-198.noarch.rpm",
+        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el8/el8-x86_64-rhui-azure-20220504/rhui-azure-rhel8-2.2-198.noarch.rpm",
         "checksum": "sha256:10e429e047cea577ade1d52422a39af6349b4427b6aba8865dc4eba169c142ce"
       }
     ]
diff --git a/test/data/manifests/rhel_86-x86_64-azure_rhui-boot.json b/test/data/manifests/rhel_86-x86_64-azure_rhui-boot.json
index a08d846c0..7c8f24a99 100644
--- a/test/data/manifests/rhel_86-x86_64-azure_rhui-boot.json
+++ b/test/data/manifests/rhel_86-x86_64-azure_rhui-boot.json
@@ -2826,15 +2826,23 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-nouveau.conf",
+              "filename": "blacklist-amdgpu.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "nouveau"
-                },
+                  "modulename": "amdgpu"
+                }
+              ]
+            }
+          },
+          {
+            "type": "org.osbuild.modprobe",
+            "options": {
+              "filename": "blacklist-intel-cstate.conf",
+              "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "lbm-nouveau"
+                  "modulename": "intel_cstate"
                 }
               ]
             }
@@ -2854,11 +2862,15 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-skylake-edac.conf",
+              "filename": "blacklist-nouveau.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "skx_edac"
+                  "modulename": "nouveau"
+                },
+                {
+                  "command": "blacklist",
+                  "modulename": "lbm-nouveau"
                 }
               ]
             }
@@ -2866,11 +2878,11 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-intel-cstate.conf",
+              "filename": "blacklist-skylake-edac.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "intel_cstate"
+                  "modulename": "skx_edac"
                 }
               ]
             }
diff --git a/test/data/manifests/rhel_87-x86_64-azure_rhui-boot.json b/test/data/manifests/rhel_87-x86_64-azure_rhui-boot.json
index 240faabff..6457cb2ce 100644
--- a/test/data/manifests/rhel_87-x86_64-azure_rhui-boot.json
+++ b/test/data/manifests/rhel_87-x86_64-azure_rhui-boot.json
@@ -2826,15 +2826,23 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-nouveau.conf",
+              "filename": "blacklist-amdgpu.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "nouveau"
-                },
+                  "modulename": "amdgpu"
+                }
+              ]
+            }
+          },
+          {
+            "type": "org.osbuild.modprobe",
+            "options": {
+              "filename": "blacklist-intel-cstate.conf",
+              "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "lbm-nouveau"
+                  "modulename": "intel_cstate"
                 }
               ]
             }
@@ -2854,11 +2862,15 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-skylake-edac.conf",
+              "filename": "blacklist-nouveau.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "skx_edac"
+                  "modulename": "nouveau"
+                },
+                {
+                  "command": "blacklist",
+                  "modulename": "lbm-nouveau"
                 }
               ]
             }
@@ -2866,11 +2878,11 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-intel-cstate.conf",
+              "filename": "blacklist-skylake-edac.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "intel_cstate"
+                  "modulename": "skx_edac"
                 }
               ]
             }
diff --git a/test/data/manifests/rhel_90-x86_64-azure_rhui-boot.json b/test/data/manifests/rhel_90-x86_64-azure_rhui-boot.json
index 90f2f8027..76285f897 100644
--- a/test/data/manifests/rhel_90-x86_64-azure_rhui-boot.json
+++ b/test/data/manifests/rhel_90-x86_64-azure_rhui-boot.json
@@ -2703,15 +2703,11 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-nouveau.conf",
+              "filename": "blacklist-amdgpu.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "nouveau"
-                },
-                {
-                  "command": "blacklist",
-                  "modulename": "lbm-nouveau"
+                  "modulename": "amdgpu"
                 }
               ]
             }
@@ -2728,6 +2724,22 @@
               ]
             }
           },
+          {
+            "type": "org.osbuild.modprobe",
+            "options": {
+              "filename": "blacklist-nouveau.conf",
+              "commands": [
+                {
+                  "command": "blacklist",
+                  "modulename": "nouveau"
+                },
+                {
+                  "command": "blacklist",
+                  "modulename": "lbm-nouveau"
+                }
+              ]
+            }
+          },
           {
             "type": "org.osbuild.systemd.unit",
             "options": {
diff --git a/test/data/manifests/rhel_91-x86_64-azure_rhui-boot.json b/test/data/manifests/rhel_91-x86_64-azure_rhui-boot.json
index 4e27b115a..51dbf350e 100644
--- a/test/data/manifests/rhel_91-x86_64-azure_rhui-boot.json
+++ b/test/data/manifests/rhel_91-x86_64-azure_rhui-boot.json
@@ -6889,15 +6889,11 @@
           {
             "type": "org.osbuild.modprobe",
             "options": {
-              "filename": "blacklist-nouveau.conf",
+              "filename": "blacklist-amdgpu.conf",
               "commands": [
                 {
                   "command": "blacklist",
-                  "modulename": "nouveau"
-                },
-                {
-                  "command": "blacklist",
-                  "modulename": "lbm-nouveau"
+                  "modulename": "amdgpu"
                 }
               ]
             }
@@ -6914,6 +6910,22 @@
               ]
             }
           },
+          {
+            "type": "org.osbuild.modprobe",
+            "options": {
+              "filename": "blacklist-nouveau.conf",
+              "commands": [
+                {
+                  "command": "blacklist",
+                  "modulename": "nouveau"
+                },
+                {
+                  "command": "blacklist",
+                  "modulename": "lbm-nouveau"
+                }
+              ]
+            }
+          },
           {
             "type": "org.osbuild.systemd.unit",
             "options": {