Repository: osbuild/osbuild-composer
Pull request #2214 (2022-01-22 17:40:43)

cloupapi/v2: add koji support
Extend the cloud API to support koji.

The aim is to replace the current koji API with this one, once it reaches feature parity, and the clients have been redeployed to support this instead.

This will allow koji to work against composer on api.openshift.com, as the current koji API is incompatible with that. It also allows us to have one fewer APIs to maintain.

The API is extended in a backwards compatible way (I hope!), which makes it slightly ugly in places, but probably fine to do it like this rather than introduce a v3 (which we should do eventually anyway when we have a better idea of how it will all end up).

We must ensure that all the same image types are accessible through the new API as were accessible through the existing one.

This pull request includes:

- [x] adequate testing for the new functionality or fixed issue

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`
-->


teg: @Gundersanne I agree with the suggested change to the internal `*JobStatus()` API, but let's leave that for a follow-up? Otherwise: addressed all comments.
teg: > There might be a gap in the error handling. It seems `JobError` is never set for a failed `KojiOSBuildJob`. Compare

You are right, and our backwards compatibility for `OSBuildJob` is also incomplete (does not handle the case when osbuild failed to run at all, so output is nil). I fixed it in the worker API, but we should also improve the actual job implementation for osbuild-koji to be on par (or maybe share code?) with the osbuild one.

Could you have a look at that separately @kingsleyzissou, just to make sure I didn't miss any cases?

Patch:
diff --git a/cmd/osbuild-worker/jobimpl-koji-finalize.go b/cmd/osbuild-worker/jobimpl-koji-finalize.go
index 48271187b..2c7d1e16d 100644
--- a/cmd/osbuild-worker/jobimpl-koji-finalize.go
+++ b/cmd/osbuild-worker/jobimpl-koji-finalize.go
@@ -233,7 +233,9 @@ func hasFailedDependency(kojiInitResult worker.KojiInitJobResult, osbuildKojiRes
 	}
 
 	for _, r := range osbuildKojiResults {
-		if !r.OSBuildOutput.Success || r.JobError != nil {
+		// 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
 		}
 	}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild-koji.go b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
index aa59cd1a3..ba87a904c 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild-koji.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
@@ -87,6 +87,29 @@ func (impl *OSBuildKojiJobImpl) Run(job worker.Job) error {
 		return err
 	}
 
+	// In case the manifest is empty, try to get it from dynamic args
+	if len(args.Manifest) == 0 {
+		if job.NDynamicArgs() > 1 {
+			var manifestJR worker.ManifestJobByIDResult
+			err = job.DynamicArgs(1, &manifestJR)
+			if err != nil {
+				return err
+			}
+
+			// skip the job if the manifest generation failed
+			if manifestJR.JobError != nil {
+				result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorManifestDependency, "Manifest dependency failed")
+				return nil
+			}
+			args.Manifest = manifestJR.Manifest
+			if len(args.Manifest) == 0 {
+				return fmt.Errorf("received empty manifest")
+			}
+		} else {
+			return fmt.Errorf("job has no manifest")
+		}
+	}
+
 	if initArgs.JobError == nil {
 		exports := args.Exports
 		if len(exports) == 0 {
diff --git a/cmd/osbuild-worker/osbuild.go b/cmd/osbuild-worker/osbuild.go
index b8c1546a3..a769b031a 100644
--- a/cmd/osbuild-worker/osbuild.go
+++ b/cmd/osbuild-worker/osbuild.go
@@ -42,9 +42,9 @@ func RunOSBuild(manifest distro.Manifest, store, outputDirectory string, exports
 		return nil, fmt.Errorf("error starting osbuild: %v", err)
 	}
 
-	err = json.NewEncoder(stdin).Encode(manifest)
+	_, err = stdin.Write(manifest)
 	if err != nil {
-		return nil, fmt.Errorf("error encoding osbuild pipeline: %v", err)
+		return nil, fmt.Errorf("error writing osbuild manifest: %v", err)
 	}
 
 	err = stdin.Close()
diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go
index d3ce3da8b..11ae42e4a 100644
--- a/internal/cloudapi/v2/errors.go
+++ b/internal/cloudapi/v2/errors.go
@@ -37,6 +37,8 @@ const (
 	ErrorMethodNotAllowed             ServiceErrorCode = 22
 	ErrorNotAcceptable                ServiceErrorCode = 23
 	ErrorNoBaseURLInPayloadRepository ServiceErrorCode = 24
+	ErrorInvalidNumberOfImageBuilds   ServiceErrorCode = 25
+	ErrorInvalidJobType               ServiceErrorCode = 26
 
 	// Internal errors, these are bugs
 	ErrorFailedToInitializeBlueprint              ServiceErrorCode = 1000
@@ -54,6 +56,7 @@ const (
 	ErrorMalformedOSBuildJobResult                ServiceErrorCode = 1012
 	ErrorGettingDepsolveJobStatus                 ServiceErrorCode = 1013
 	ErrorDepsolveJobCanceled                      ServiceErrorCode = 1014
+	ErrorUnexpectedNumberOfImageBuilds            ServiceErrorCode = 1015
 
 	// Errors contained within this file
 	ErrorUnspecified          ServiceErrorCode = 10000
@@ -97,6 +100,8 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorMethodNotAllowed, http.StatusMethodNotAllowed, "Requested method isn't supported for resource"},
 		serviceError{ErrorNotAcceptable, http.StatusNotAcceptable, "Only 'application/json' content is supported"},
 		serviceError{ErrorNoBaseURLInPayloadRepository, http.StatusBadRequest, "BaseURL must be specified for payload repositories"},
+		serviceError{ErrorInvalidJobType, http.StatusNotFound, "Requested job has invalid type"},
+		serviceError{ErrorInvalidNumberOfImageBuilds, http.StatusBadRequest, "Compose request has unsupported number of image builds"},
 
 		serviceError{ErrorFailedToInitializeBlueprint, http.StatusInternalServerError, "Failed to initialize blueprint"},
 		serviceError{ErrorFailedToGenerateManifestSeed, http.StatusInternalServerError, "Failed to generate manifest seed"},
@@ -113,6 +118,7 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorMalformedOSBuildJobResult, http.StatusInternalServerError, "OSBuildJobResult does not have expected fields set"},
 		serviceError{ErrorGettingDepsolveJobStatus, http.StatusInternalServerError, "Unable to get depsolve job status"},
 		serviceError{ErrorDepsolveJobCanceled, http.StatusInternalServerError, "Depsolve job was cancelled"},
+		serviceError{ErrorUnexpectedNumberOfImageBuilds, http.StatusInternalServerError, "Compose has unexpected number of image builds"},
 
 		serviceError{ErrorUnspecified, http.StatusInternalServerError, "Unspecified internal error "},
 		serviceError{ErrorNotHTTPError, http.StatusInternalServerError, "Error is not an instance of HTTPError"},
diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go
index 349a48900..632bcefcd 100644
--- a/internal/cloudapi/v2/openapi.v2.gen.go
+++ b/internal/cloudapi/v2/openapi.v2.gen.go
@@ -22,6 +22,15 @@ const (
 	BearerScopes = "Bearer.Scopes"
 )
 
+// Defines values for ComposeStatusValue.
+const (
+	ComposeStatusValueFailure ComposeStatusValue = "failure"
+
+	ComposeStatusValuePending ComposeStatusValue = "pending"
+
+	ComposeStatusValueSuccess ComposeStatusValue = "success"
+)
+
 // Defines values for ImageStatusValue.
 const (
 	ImageStatusValueBuilding ImageStatusValue = "building"
@@ -140,6 +149,23 @@ type ComposeId struct {
 	Id string `json:"id"`
 }
 
+// ComposeLogs defines model for ComposeLogs.
+type ComposeLogs struct {
+	// Embedded struct due to allOf(#/components/schemas/ObjectReference)
+	ObjectReference `yaml:",inline"`
+	// Embedded fields due to inline allOf schema
+	ImageBuilds []interface{} `json:"image_builds"`
+	Koji        *KojiLogs     `json:"koji,omitempty"`
+}
+
+// ComposeManifests defines model for ComposeManifests.
+type ComposeManifests struct {
+	// Embedded struct due to allOf(#/components/schemas/ObjectReference)
+	ObjectReference `yaml:",inline"`
+	// Embedded fields due to inline allOf schema
+	Manifests []interface{} `json:"manifests"`
+}
+
 // ComposeMetadata defines model for ComposeMetadata.
 type ComposeMetadata struct {
 	// Embedded struct due to allOf(#/components/schemas/ObjectReference)
@@ -156,7 +182,9 @@ type ComposeMetadata struct {
 type ComposeRequest struct {
 	Customizations *Customizations `json:"customizations,omitempty"`
 	Distribution   string          `json:"distribution"`
-	ImageRequest   ImageRequest    `json:"image_request"`
+	ImageRequest   *ImageRequest   `json:"image_request,omitempty"`
+	ImageRequests  *[]ImageRequest `json:"image_requests,omitempty"`
+	Koji           *Koji           `json:"koji,omitempty"`
 }
 
 // ComposeStatus defines model for ComposeStatus.
@@ -164,9 +192,15 @@ type ComposeStatus struct {
 	// Embedded struct due to allOf(#/components/schemas/ObjectReference)
 	ObjectReference `yaml:",inline"`
 	// Embedded fields due to inline allOf schema
-	ImageStatus ImageStatus `json:"image_status"`
+	ImageStatus   ImageStatus        `json:"image_status"`
+	ImageStatuses *[]ImageStatus     `json:"image_statuses,omitempty"`
+	KojiStatus    *KojiStatus        `json:"koji_status,omitempty"`
+	Status        ComposeStatusValue `json:"status"`
 }
 
+// ComposeStatusValue defines model for ComposeStatusValue.
+type ComposeStatusValue string
+
 // Customizations defines model for Customizations.
 type Customizations struct {
 	Packages            *[]string     `json:"packages,omitempty"`
@@ -233,11 +267,11 @@ type GCPUploadStatus struct {
 
 // ImageRequest defines model for ImageRequest.
 type ImageRequest struct {
-	Architecture  string        `json:"architecture"`
-	ImageType     ImageTypes    `json:"image_type"`
-	Ostree        *OSTree       `json:"ostree,omitempty"`
-	Repositories  []Repository  `json:"repositories"`
-	UploadOptions UploadOptions `json:"upload_options"`
+	Architecture  string         `json:"architecture"`
+	ImageType     ImageTypes     `json:"image_type"`
+	Ostree        *OSTree        `json:"ostree,omitempty"`
+	Repositories  []Repository   `json:"repositories"`
+	UploadOptions *UploadOptions `json:"upload_options,omitempty"`
 }
 
 // ImageStatus defines model for ImageStatus.
@@ -252,6 +286,26 @@ type ImageStatusValue string
 // ImageTypes defines model for ImageTypes.
 type ImageTypes string
 
+// Koji defines model for Koji.
+type Koji struct {
+	Name    string `json:"name"`
+	Release string `json:"release"`
+	Server  string `json:"server"`
+	TaskId  int    `json:"task_id"`
+	Version string `json:"version"`
+}
+
+// KojiLogs defines model for KojiLogs.
+type KojiLogs struct {
+	Import interface{} `json:"import"`
+	Init   interface{} `json:"init"`
+}
+
+// KojiStatus defines model for KojiStatus.
+type KojiStatus struct {
+	BuildId *int `json:"build_id,omitempty"`
+}
+
 // List defines model for List.
 type List struct {
 	Kind  string `json:"kind"`
@@ -360,6 +414,12 @@ type ServerInterface interface {
 	// The status of a compose
 	// (GET /composes/{id})
 	GetComposeStatus(ctx echo.Context, id string) error
+	// Get logs for a compose.
+	// (GET /composes/{id}/logs)
+	GetComposeLogs(ctx echo.Context, id string) error
+	// Get the manifests for a compose.
+	// (GET /composes/{id}/manifests)
+	GetComposeManifests(ctx echo.Context, id string) error
 	// Get the metadata for a compose.
 	// (GET /composes/{id}/metadata)
 	GetComposeMetadata(ctx echo.Context, id string) error
@@ -408,6 +468,38 @@ func (w *ServerInterfaceWrapper) GetComposeStatus(ctx echo.Context) error {
 	return err
 }
 
+// GetComposeLogs converts echo context to params.
+func (w *ServerInterfaceWrapper) GetComposeLogs(ctx echo.Context) error {
+	var err error
+	// ------------- Path parameter "id" -------------
+	var id string
+
+	err = runtime.BindStyledParameterWithLocation("simple", false, "id", runtime.ParamLocationPath, ctx.Param("id"), &id)
+	if err != nil {
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err))
+	}
+
+	// Invoke the callback with all the unmarshalled arguments
+	err = w.Handler.GetComposeLogs(ctx, id)
+	return err
+}
+
+// GetComposeManifests converts echo context to params.
+func (w *ServerInterfaceWrapper) GetComposeManifests(ctx echo.Context) error {
+	var err error
+	// ------------- Path parameter "id" -------------
+	var id string
+
+	err = runtime.BindStyledParameterWithLocation("simple", false, "id", runtime.ParamLocationPath, ctx.Param("id"), &id)
+	if err != nil {
+		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err))
+	}
+
+	// Invoke the callback with all the unmarshalled arguments
+	err = w.Handler.GetComposeManifests(ctx, id)
+	return err
+}
+
 // GetComposeMetadata converts echo context to params.
 func (w *ServerInterfaceWrapper) GetComposeMetadata(ctx echo.Context) error {
 	var err error
@@ -512,6 +604,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
 
 	router.POST(baseURL+"/compose", wrapper.PostCompose)
 	router.GET(baseURL+"/composes/:id", wrapper.GetComposeStatus)
+	router.GET(baseURL+"/composes/:id/logs", wrapper.GetComposeLogs)
+	router.GET(baseURL+"/composes/:id/manifests", wrapper.GetComposeManifests)
 	router.GET(baseURL+"/composes/:id/metadata", wrapper.GetComposeMetadata)
 	router.GET(baseURL+"/errors", wrapper.GetErrorList)
 	router.GET(baseURL+"/errors/:id", wrapper.GetError)
@@ -522,66 +616,74 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"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",
+	"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",
+	"SwAA",
 }
 
 // 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 902eb0c62..1c312c285 100644
--- a/internal/cloudapi/v2/openapi.v2.yml
+++ b/internal/cloudapi/v2/openapi.v2.yml
@@ -144,6 +144,72 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/Error'
+  '/composes/{id}/logs':
+    get:
+      operationId: getComposeLogs
+      summary: Get logs for a compose.
+      parameters:
+        - in: path
+          name: id
+          schema:
+            type: string
+            format: uuid
+            example: 123e4567-e89b-12d3-a456-426655440000
+          required: true
+          description: ID of compose status to get
+      description: 'Get the status of a running or finished compose. This includes whether or not it succeeded, and also meta information about the result.'
+      responses:
+        '200':
+          description: The logs for the given compose, in no particular format (though valid JSON).
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ComposeLogs'
+        '400':
+          description: Invalid compose id
+          content:
+            text/plain:
+              schema:
+                type: string
+        '404':
+          description: Unknown compose id
+          content:
+            text/plain:
+              schema:
+                type: string
+  '/composes/{id}/manifests':
+    get:
+      operationId: getComposeManifests
+      summary: Get the manifests for a compose.
+      parameters:
+        - in: path
+          name: id
+          schema:
+            type: string
+            format: uuid
+            example: 123e4567-e89b-12d3-a456-426655440000
+          required: true
+          description: ID of compose status to get
+      description: 'Get the manifests of a running or finished compose. Returns one manifest for each image in the request. Each manifest conforms to the format defined at https://www.osbuild.org/man/osbuild-manifest.5'
+      responses:
+        '200':
+          description: The manifest for the given compose.
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ComposeManifests'
+        '400':
+          description: Invalid compose id
+          content:
+            text/plain:
+              schema:
+                type: string
+        '404':
+          description: Unknown compose id
+          content:
+            text/plain:
+              schema:
+                type: string
 
   /compose:
     post:
@@ -350,10 +416,54 @@ components:
       - $ref: '#/components/schemas/ObjectReference'
       - type: object
         required:
+          - status
           - image_status
         properties:
+          status:
+            $ref: '#/components/schemas/ComposeStatusValue'
           image_status:
             $ref: '#/components/schemas/ImageStatus'
+          image_statuses:
+            type: array
+            items:
+              $ref: '#/components/schemas/ImageStatus'
+          koji_status:
+            $ref: '#/components/schemas/KojiStatus'
+    ComposeStatusValue:
+      type: string
+      enum:
+        - success
+        - failure
+        - pending
+      example: success
+    ComposeLogs:
+      allOf:
+      - $ref: '#/components/schemas/ObjectReference'
+      - type: object
+        required:
+          - image_builds
+        properties:
+          image_builds:
+            type: array
+          koji:
+            $ref: '#/components/schemas/KojiLogs'
+    KojiLogs:
+      type: object
+      required:
+        - init
+        - import
+      properties:
+        init: {}
+        import: {}
+    ComposeManifests:
+      allOf:
+      - $ref: '#/components/schemas/ObjectReference'
+      - type: object
+        required:
+          - manifests
+        properties:
+          manifests:
+            type: array
     ImageStatus:
       required:
        - status
@@ -430,6 +540,12 @@ components:
         image_name:
           type: string
           example: 'my-image'
+    KojiStatus:
+      type: object
+      properties:
+        build_id:
+          type: integer
+          example: 42
 
     ComposeMetadata:
       allOf:
@@ -473,21 +589,25 @@ components:
     ComposeRequest:
       required:
         - distribution
-        - image_request
       properties:
         distribution:
           type: string
           example: 'rhel-8'
         image_request:
           $ref: '#/components/schemas/ImageRequest'
+        image_requests:
+          type: array
+          items:
+            $ref: '#/components/schemas/ImageRequest'
         customizations:
           $ref: '#/components/schemas/Customizations'
+        koji:
+          $ref: '#/components/schemas/Koji'
     ImageRequest:
       required:
         - architecture
         - image_type
         - repositories
-        - upload_options
       properties:
         architecture:
           type: string
@@ -733,7 +853,31 @@ components:
           key:
             type: string
             example: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrGKErMYi+MMUwuHaRAJmRLoIzRf2qD2dD5z0BTx/6x"
-
+    Koji:
+      type: object
+      required:
+        - server
+        - task_id
+        - name
+        - version
+        - release
+      properties:
+        server:
+          type: string
+          format: url
+          example: 'https://koji.fedoraproject.org/kojihub'
+        task_id:
+          type: integer
+          example: 42
+        name:
+          type: string
+          example: Fedora-Cloud-Base
+        version:
+          type: string
+          example: '31'
+        release:
+          type: string
+          example: '20200907.0'
     ComposeId:
       allOf:
       - $ref: '#/components/schemas/ObjectReference'
diff --git a/internal/cloudapi/v2/v2.go b/internal/cloudapi/v2/v2.go
index 4537e8b91..86c43e85d 100644
--- a/internal/cloudapi/v2/v2.go
+++ b/internal/cloudapi/v2/v2.go
@@ -10,6 +10,7 @@ import (
 	"math/big"
 	"net/http"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
@@ -127,6 +128,34 @@ func (h *apiHandlers) GetError(ctx echo.Context, id string) error {
 	return ctx.JSON(http.StatusOK, apiError)
 }
 
+// splitExtension returns the extension of the given file. If there's
+// a multipart extension (e.g. file.tar.gz), it returns all parts (e.g.
+// .tar.gz). If there's no extension in the input, it returns an empty
+// string. If the filename starts with dot, the part before the second dot
+// is not considered as an extension.
+func splitExtension(filename string) string {
+	filenameParts := strings.Split(filename, ".")
+
+	if len(filenameParts) > 0 && filenameParts[0] == "" {
+		filenameParts = filenameParts[1:]
+	}
+
+	if len(filenameParts) <= 1 {
+		return ""
+	}
+
+	return "." + strings.Join(filenameParts[1:], ".")
+}
+
+type imageRequest struct {
+	imageType               distro.ImageType
+	arch                    distro.Arch
+	repositories            []rpmmd.RepoConfig
+	packageSetsRepositories map[string][]rpmmd.RepoConfig
+	imageOptions            distro.ImageOptions
+	target                  *target.Target
+}
+
 func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	var request ComposeRequest
 	err := ctx.Bind(&request)
@@ -144,6 +173,30 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	if err != nil {
 		return HTTPErrorWithInternal(ErrorFailedToInitializeBlueprint, err)
 	}
+
+	// Set the blueprint customisation to take care of the user
+	if request.Customizations != nil && request.Customizations.Users != nil {
+		var userCustomizations []blueprint.UserCustomization
+		for _, user := range *request.Customizations.Users {
+			var groups []string
+			if user.Groups != nil {
+				groups = *user.Groups
+			} else {
+				groups = nil
+			}
+			userCustomizations = append(userCustomizations,
+				blueprint.UserCustomization{
+					Name:   user.Name,
+					Key:    user.Key,
+					Groups: groups,
+				},
+			)
+		}
+		bp.Customizations = &blueprint.Customizations{
+			User: userCustomizations,
+		}
+	}
+
 	if request.Customizations != nil && request.Customizations.Packages != nil {
 		for _, p := range *request.Customizations.Packages {
 			bp.Packages = append(bp.Packages, blueprint.Package{
@@ -164,363 +217,476 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	}
 	manifestSeed := bigSeed.Int64()
 
-	ir := request.ImageRequest
-	arch, err := distribution.GetArch(ir.Architecture)
-	if err != nil {
-		return HTTPError(ErrorUnsupportedArchitecture)
-	}
-	imageType, err := arch.GetImageType(imageTypeFromApiImageType(ir.ImageType))
-	if err != nil {
-		return HTTPError(ErrorUnsupportedImageType)
-	}
-	repositories := make([]rpmmd.RepoConfig, len(ir.Repositories))
-	for j, repo := range ir.Repositories {
-		repositories[j].RHSM = repo.Rhsm
-
-		if repo.Baseurl != nil {
-			repositories[j].BaseURL = *repo.Baseurl
-		} else if repo.Mirrorlist != nil {
-			repositories[j].MirrorList = *repo.Mirrorlist
-		} else if repo.Metalink != nil {
-			repositories[j].Metalink = *repo.Metalink
-		} else {
-			return HTTPError(ErrorInvalidRepository)
+	// For backwards compatibility, we support both a single image request
+	// as well as an array of requests in the API. Exactly one must be
+	// specified.
+	if request.ImageRequest != nil {
+		if request.ImageRequests != nil {
+			// we should really be using oneOf in the spec
+			return HTTPError(ErrorInvalidNumberOfImageBuilds)
 		}
+		request.ImageRequests = &[]ImageRequest{*request.ImageRequest}
 	}
+	if request.ImageRequests == nil {
+		return HTTPError(ErrorInvalidNumberOfImageBuilds)
+	}
+	var irs []imageRequest
+	for _, ir := range *request.ImageRequests {
+		arch, err := distribution.GetArch(ir.Architecture)
+		if err != nil {
+			return HTTPError(ErrorUnsupportedArchitecture)
+		}
+		imageType, err := arch.GetImageType(imageTypeFromApiImageType(ir.ImageType))
+		if err != nil {
+			return HTTPError(ErrorUnsupportedImageType)
+		}
+		repositories := make([]rpmmd.RepoConfig, len(ir.Repositories))
+		for j, repo := range ir.Repositories {
+			repositories[j].RHSM = repo.Rhsm
 
-	payloadPackageSets := imageType.PayloadPackageSets()
-	packageSetsRepositories := make(map[string][]rpmmd.RepoConfig, len(payloadPackageSets))
-
-	for _, packageSetKey := range payloadPackageSets {
-		packageSetsRepositories[packageSetKey] = make([]rpmmd.RepoConfig, len(payloadRepositories))
-		for j, repo := range payloadRepositories {
 			if repo.Baseurl != nil {
-				packageSetsRepositories[packageSetKey][j].BaseURL = *repo.Baseurl
+				repositories[j].BaseURL = *repo.Baseurl
+			} else if repo.Mirrorlist != nil {
+				repositories[j].MirrorList = *repo.Mirrorlist
+			} else if repo.Metalink != nil {
+				repositories[j].Metalink = *repo.Metalink
 			} else {
-				return HTTPError(ErrorNoBaseURLInPayloadRepository)
-			}
-			if repo.GpgKey != nil {
-				packageSetsRepositories[packageSetKey][j].GPGKey = *repo.GpgKey
-			}
-			if repo.CheckGpg != nil {
-				packageSetsRepositories[packageSetKey][j].CheckGPG = *repo.CheckGpg
-			}
-			if repo.IgnoreSsl != nil {
-				packageSetsRepositories[packageSetKey][j].IgnoreSSL = *repo.IgnoreSsl
+				return HTTPError(ErrorInvalidRepository)
 			}
 		}
-	}
 
-	packageSets := imageType.PackageSets(bp)
-	depsolveJobID, err := h.server.workers.EnqueueDepsolve(&worker.DepsolveJob{
-		PackageSets:             packageSets,
-		Repos:                   repositories,
-		ModulePlatformID:        distribution.ModulePlatformID(),
-		Arch:                    arch.Name(),
-		Releasever:              distribution.Releasever(),
-		PackageSetsRepositories: packageSetsRepositories,
-	})
-	if err != nil {
-		return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
-	}
+		payloadPackageSets := imageType.PayloadPackageSets()
+		packageSetsRepositories := make(map[string][]rpmmd.RepoConfig, len(payloadPackageSets))
 
-	imageOptions := distro.ImageOptions{Size: imageType.Size(0)}
-	if request.Customizations != nil && request.Customizations.Subscription != nil {
-		imageOptions.Subscription = &distro.SubscriptionImageOptions{
-			Organization:  request.Customizations.Subscription.Organization,
-			ActivationKey: request.Customizations.Subscription.ActivationKey,
-			ServerUrl:     request.Customizations.Subscription.ServerUrl,
-			BaseUrl:       request.Customizations.Subscription.BaseUrl,
-			Insights:      request.Customizations.Subscription.Insights,
+		for _, packageSetKey := range payloadPackageSets {
+			packageSetsRepositories[packageSetKey] = make([]rpmmd.RepoConfig, len(payloadRepositories))
+			for j, repo := range payloadRepositories {
+				if repo.Baseurl != nil {
+					packageSetsRepositories[packageSetKey][j].BaseURL = *repo.Baseurl
+				} else {
+					return HTTPError(ErrorNoBaseURLInPayloadRepository)
+				}
+				if repo.GpgKey != nil {
+					packageSetsRepositories[packageSetKey][j].GPGKey = *repo.GpgKey
+				}
+				if repo.CheckGpg != nil {
+					packageSetsRepositories[packageSetKey][j].CheckGPG = *repo.CheckGpg
+				}
+				if repo.IgnoreSsl != nil {
+					packageSetsRepositories[packageSetKey][j].IgnoreSSL = *repo.IgnoreSsl
+				}
+			}
 		}
-	}
 
-	// set default ostree ref, if one not provided
-	ostreeOptions := ir.Ostree
-	if ostreeOptions == nil || ostreeOptions.Ref == nil {
-		imageOptions.OSTree = distro.OSTreeImageOptions{Ref: imageType.OSTreeRef()}
-	} else if !ostree.VerifyRef(*ostreeOptions.Ref) {
-		return HTTPError(ErrorInvalidOSTreeRef)
-	} else {
-		imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
-	}
+		imageOptions := distro.ImageOptions{Size: imageType.Size(0)}
+		if request.Customizations != nil && request.Customizations.Subscription != nil {
+			imageOptions.Subscription = &distro.SubscriptionImageOptions{
+				Organization:  request.Customizations.Subscription.Organization,
+				ActivationKey: request.Customizations.Subscription.ActivationKey,
+				ServerUrl:     request.Customizations.Subscription.ServerUrl,
+				BaseUrl:       request.Customizations.Subscription.BaseUrl,
+				Insights:      request.Customizations.Subscription.Insights,
+			}
+		}
 
-	var parent string
-	if ostreeOptions != nil && ostreeOptions.Url != nil {
-		imageOptions.OSTree.URL = *ostreeOptions.Url
-		parent, err = ostree.ResolveRef(imageOptions.OSTree.URL, imageOptions.OSTree.Ref)
-		if err != nil {
-			return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, err)
+		// set default ostree ref, if one not provided
+		ostreeOptions := ir.Ostree
+		if ostreeOptions == nil || ostreeOptions.Ref == nil {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: imageType.OSTreeRef()}
+		} else if !ostree.VerifyRef(*ostreeOptions.Ref) {
+			return HTTPError(ErrorInvalidOSTreeRef)
+		} else {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
 		}
-		imageOptions.OSTree.Parent = parent
-	}
 
-	// Set the blueprint customisation to take care of the user
-	var blueprintCustoms *blueprint.Customizations
-	if request.Customizations != nil && request.Customizations.Users != nil {
-		var userCustomizations []blueprint.UserCustomization
-		for _, user := range *request.Customizations.Users {
-			var groups []string
-			if user.Groups != nil {
-				groups = *user.Groups
-			} else {
-				groups = nil
+		var parent string
+		if ostreeOptions != nil && ostreeOptions.Url != nil {
+			imageOptions.OSTree.URL = *ostreeOptions.Url
+			parent, err = ostree.ResolveRef(imageOptions.OSTree.URL, imageOptions.OSTree.Ref)
+			if err != nil {
+				return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, err)
 			}
-			userCustomizations = append(userCustomizations,
-				blueprint.UserCustomization{
-					Name:   user.Name,
-					Key:    user.Key,
-					Groups: groups,
-				},
-			)
-		}
-		blueprintCustoms = &blueprint.Customizations{
-			User: userCustomizations,
+			imageOptions.OSTree.Parent = parent
 		}
-	}
 
-	var irTarget *target.Target
-	/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
-	switch ir.ImageType {
-	case ImageTypesAws:
-		var awsUploadOptions AWSEC2UploadOptions
-		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONMarshallingError)
-		}
-		err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONUnMarshallingError)
-		}
-
-		// For service maintenance, images are discovered by the "Name:composer-api-*"
-		// tag filter. Currently all image names in the service are generated, so they're
-		// guaranteed to be unique as well. If users are ever allowed to name their images,
-		// an extra tag should be added.
-		key := fmt.Sprintf("composer-api-%s", uuid.New().String())
-		t := target.NewAWSTarget(&target.AWSTargetOptions{
-			Filename:          imageType.Filename(),
-			Region:            awsUploadOptions.Region,
-			Bucket:            h.server.awsBucket,
-			Key:               key,
-			ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
-		})
-		if awsUploadOptions.SnapshotName != nil {
-			t.ImageName = *awsUploadOptions.SnapshotName
+		var irTarget *target.Target
+		if ir.UploadOptions == nil {
+			// nowhere to put the image, this is a user error
+			if request.Koji == nil {
+				return HTTPError(ErrorJSONUnMarshallingError)
+			}
 		} else {
-			t.ImageName = key
-		}
+			// TODO: support uploads also for koji
+			if request.Koji != nil {
+				return HTTPError(ErrorJSONUnMarshallingError)
+			}
+			/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
+			switch ir.ImageType {
+			case ImageTypesAws:
+				var awsUploadOptions AWSEC2UploadOptions
+				jsonUploadOptions, err := json.Marshal(*ir.UploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONMarshallingError)
+				}
+				err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONUnMarshallingError)
+				}
 
-		irTarget = t
-	case ImageTypesGuestImage:
-		fallthrough
-	case ImageTypesVsphere:
-		fallthrough
-	case ImageTypesImageInstaller:
-		fallthrough
-	case ImageTypesEdgeInstaller:
-		fallthrough
-	case ImageTypesEdgeContainer:
-		fallthrough
-	case ImageTypesEdgeCommit:
-		var awsS3UploadOptions AWSS3UploadOptions
-		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONMarshallingError)
-		}
-		err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONUnMarshallingError)
-		}
+				// For service maintenance, images are discovered by the "Name:composer-api-*"
+				// tag filter. Currently all image names in the service are generated, so they're
+				// guaranteed to be unique as well. If users are ever allowed to name their images,
+				// an extra tag should be added.
+				key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+				t := target.NewAWSTarget(&target.AWSTargetOptions{
+					Filename:          imageType.Filename(),
+					Region:            awsUploadOptions.Region,
+					Bucket:            h.server.awsBucket,
+					Key:               key,
+					ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
+				})
+				if awsUploadOptions.SnapshotName != nil {
+					t.ImageName = *awsUploadOptions.SnapshotName
+				} else {
+					t.ImageName = key
+				}
 
-		key := fmt.Sprintf("composer-api-%s", uuid.New().String())
-		t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
-			Filename: imageType.Filename(),
-			Region:   awsS3UploadOptions.Region,
-			Bucket:   h.server.awsBucket,
-			Key:      key,
-		})
-		t.ImageName = key
+				irTarget = t
+			case ImageTypesGuestImage:
+				fallthrough
+			case ImageTypesVsphere:
+				fallthrough
+			case ImageTypesImageInstaller:
+				fallthrough
+			case ImageTypesEdgeInstaller:
+				fallthrough
+			case ImageTypesEdgeContainer:
+				fallthrough
+			case ImageTypesEdgeCommit:
+				var awsS3UploadOptions AWSS3UploadOptions
+				jsonUploadOptions, err := json.Marshal(*ir.UploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONMarshallingError)
+				}
+				err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONUnMarshallingError)
+				}
 
-		irTarget = t
-	case ImageTypesGcp:
-		var gcpUploadOptions GCPUploadOptions
-		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONMarshallingError)
-		}
-		err = json.Unmarshal(jsonUploadOptions, &gcpUploadOptions)
-		if err != nil {
-			return HTTPError(ErrorJSONUnMarshallingError)
-		}
+				key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+				t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
+					Filename: imageType.Filename(),
+					Region:   awsS3UploadOptions.Region,
+					Bucket:   h.server.awsBucket,
+					Key:      key,
+				})
+				t.ImageName = key
+
+				irTarget = t
+			case ImageTypesGcp:
+				var gcpUploadOptions GCPUploadOptions
+				jsonUploadOptions, err := json.Marshal(*ir.UploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONMarshallingError)
+				}
+				err = json.Unmarshal(jsonUploadOptions, &gcpUploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONUnMarshallingError)
+				}
+
+				var share []string
+				if gcpUploadOptions.ShareWithAccounts != nil {
+					share = *gcpUploadOptions.ShareWithAccounts
+				}
 
-		var share []string
-		if gcpUploadOptions.ShareWithAccounts != nil {
-			share = *gcpUploadOptions.ShareWithAccounts
+				object := fmt.Sprintf("composer-api-%s", uuid.New().String())
+				t := target.NewGCPTarget(&target.GCPTargetOptions{
+					Filename:          imageType.Filename(),
+					Region:            gcpUploadOptions.Region,
+					Os:                "", // not exposed in cloudapi for now
+					Bucket:            gcpUploadOptions.Bucket,
+					Object:            object,
+					ShareWithAccounts: share,
+				})
+				// Import will fail if an image with this name already exists
+				if gcpUploadOptions.ImageName != nil {
+					t.ImageName = *gcpUploadOptions.ImageName
+				} else {
+					t.ImageName = object
+				}
+
+				irTarget = t
+			case ImageTypesAzure:
+				var azureUploadOptions AzureUploadOptions
+				jsonUploadOptions, err := json.Marshal(*ir.UploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONMarshallingError)
+				}
+				err = json.Unmarshal(jsonUploadOptions, &azureUploadOptions)
+				if err != nil {
+					return HTTPError(ErrorJSONUnMarshallingError)
+				}
+				t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
+					Filename:       imageType.Filename(),
+					TenantID:       azureUploadOptions.TenantId,
+					Location:       azureUploadOptions.Location,
+					SubscriptionID: azureUploadOptions.SubscriptionId,
+					ResourceGroup:  azureUploadOptions.ResourceGroup,
+				})
+
+				if azureUploadOptions.ImageName != nil {
+					t.ImageName = *azureUploadOptions.ImageName
+				} else {
+					// if ImageName wasn't given, generate a random one
+					t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
+				}
+
+				irTarget = t
+			default:
+				return HTTPError(ErrorUnsupportedImageType)
+			}
 		}
 
-		object := fmt.Sprintf("composer-api-%s", uuid.New().String())
-		t := target.NewGCPTarget(&target.GCPTargetOptions{
-			Filename:          imageType.Filename(),
-			Region:            gcpUploadOptions.Region,
-			Os:                "", // not exposed in cloudapi for now
-			Bucket:            gcpUploadOptions.Bucket,
-			Object:            object,
-			ShareWithAccounts: share,
+		irs = append(irs, imageRequest{
+			imageType:               imageType,
+			arch:                    arch,
+			repositories:            repositories,
+			imageOptions:            imageOptions,
+			packageSetsRepositories: packageSetsRepositories,
+			target:                  irTarget,
 		})
-		// Import will fail if an image with this name already exists
-		if gcpUploadOptions.ImageName != nil {
-			t.ImageName = *gcpUploadOptions.ImageName
-		} else {
-			t.ImageName = object
-		}
+	}
 
-		irTarget = t
-	case ImageTypesAzure:
-		var azureUploadOptions AzureUploadOptions
-		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
+	var id uuid.UUID
+	if request.Koji != nil {
+		id, err = enqueueKojiCompose(h.server.workers, uint64(request.Koji.TaskId), request.Koji.Server, request.Koji.Name, request.Koji.Version, request.Koji.Release, distribution, bp, manifestSeed, irs)
 		if err != nil {
-			return HTTPError(ErrorJSONMarshallingError)
+			return err
 		}
-		err = json.Unmarshal(jsonUploadOptions, &azureUploadOptions)
+	} else {
+		id, err = enqueueCompose(h.server.workers, distribution, bp, manifestSeed, irs)
 		if err != nil {
-			return HTTPError(ErrorJSONUnMarshallingError)
-		}
-		t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
-			Filename:       imageType.Filename(),
-			TenantID:       azureUploadOptions.TenantId,
-			Location:       azureUploadOptions.Location,
-			SubscriptionID: azureUploadOptions.SubscriptionId,
-			ResourceGroup:  azureUploadOptions.ResourceGroup,
-		})
-
-		if azureUploadOptions.ImageName != nil {
-			t.ImageName = *azureUploadOptions.ImageName
-		} else {
-			// if ImageName wasn't given, generate a random one
-			t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
+			return err
 		}
+	}
+
+	ctx.Logger().Infof("Job ID %s enqueued for operationID %s", id, ctx.Get("operationID"))
+
+	return ctx.JSON(http.StatusCreated, &ComposeId{
+		ObjectReference: ObjectReference{
+			Href: "/api/image-builder-composer/v2/compose",
+			Id:   id.String(),
+			Kind: "ComposeId",
+		},
+		Id: id.String(),
+	})
+}
 
-		irTarget = t
-	default:
-		return HTTPError(ErrorUnsupportedImageType)
+func enqueueCompose(workers *worker.Server, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest) (uuid.UUID, error) {
+	var id uuid.UUID
+	if len(irs) != 1 {
+		return id, HTTPError(ErrorInvalidNumberOfImageBuilds)
 	}
+	ir := irs[0]
 
-	manifestJobID, err := h.server.workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID)
+	depsolveJobID, err := workers.EnqueueDepsolve(&worker.DepsolveJob{
+		PackageSets:             ir.imageType.PackageSets(bp),
+		Repos:                   ir.repositories,
+		ModulePlatformID:        distribution.ModulePlatformID(),
+		Arch:                    ir.arch.Name(),
+		Releasever:              distribution.Releasever(),
+		PackageSetsRepositories: ir.packageSetsRepositories,
+	})
 	if err != nil {
-		return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
 
-	id, err := h.server.workers.EnqueueOSBuildAsDependency(arch.Name(), &worker.OSBuildJob{
-		Targets: []*target.Target{irTarget},
-		Exports: imageType.Exports(),
+	manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID)
+	if err != nil {
+		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+	}
+
+	id, err = workers.EnqueueOSBuildAsDependency(ir.arch.Name(), &worker.OSBuildJob{
+		Targets: []*target.Target{ir.target},
+		Exports: ir.imageType.Exports(),
 		PipelineNames: &worker.PipelineNames{
-			Build:   imageType.BuildPipelines(),
-			Payload: imageType.PayloadPipelines(),
+			Build:   ir.imageType.BuildPipelines(),
+			Payload: ir.imageType.PayloadPipelines(),
 		},
 	}, manifestJobID)
 	if err != nil {
-		return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 	}
 
-	ctx.Logger().Infof("Job ID %s enqueued for operationID %s", id, ctx.Get("operationID"))
-	manifestJobContext, manifestCancel := context.WithTimeout(context.Background(), time.Minute*5)
-
-	// start 1 goroutine which requests datajob type
-	go func(workers *worker.Server, manifestJobID uuid.UUID, b *blueprint.Customizations, options distro.ImageOptions, repos []rpmmd.RepoConfig, seed int64, depsolveJobID uuid.UUID) {
-		defer manifestCancel()
-		// wait until job is in a pending state
-		var token uuid.UUID
-		var dynArgs []json.RawMessage
-		var err error
-		for {
-			_, token, _, _, dynArgs, err = workers.RequestJobById(context.Background(), "", manifestJobID)
-			if err == jobqueue.ErrNotPending {
-				logrus.Debugf("Manifest job %v not pending, waiting for depsolve job to finish", manifestJobID)
-				time.Sleep(time.Millisecond * 50)
-				select {
-				case <-manifestJobContext.Done():
-					logrus.Warnf("Manifest job %v's dependencies took longer than 5 minutes to finish, returning to avoid dangling routines", manifestJobID)
-					return
-				default:
-					continue
-				}
-			}
-			if err != nil {
-				logrus.Errorf("Error requesting manifest job: %v", err)
-				return
-			}
-			break
+	ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
+	go generateManifest(ctx, cancel, workers, depsolveJobID, manifestJobID, ir.imageType, ir.repositories, ir.imageOptions, manifestSeed, bp.Customizations)
+
+	return id, nil
+}
+
+func enqueueKojiCompose(workers *worker.Server, taskID uint64, server, name, version, release string, distribution distro.Distro, bp blueprint.Blueprint, manifestSeed int64, irs []imageRequest) (uuid.UUID, error) {
+	var id uuid.UUID
+	kojiDirectory := "osbuild-composer-koji-" + uuid.New().String()
+
+	initID, err := workers.EnqueueKojiInit(&worker.KojiInitJob{
+		Server:  server,
+		Name:    name,
+		Version: version,
+		Release: release,
+	})
+	if err != nil {
+		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+	}
+
+	var kojiFilenames []string
+	var buildIDs []uuid.UUID
+	for _, ir := range irs {
+		depsolveJobID, err := workers.EnqueueDepsolve(&worker.DepsolveJob{
+			PackageSets:             ir.imageType.PackageSets(bp),
+			Repos:                   ir.repositories,
+			ModulePlatformID:        distribution.ModulePlatformID(),
+			Arch:                    ir.arch.Name(),
+			Releasever:              distribution.Releasever(),
+			PackageSetsRepositories: ir.packageSetsRepositories,
+		})
+		if err != nil {
+			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
 
-		var jobResult *worker.ManifestJobByIDResult = &worker.ManifestJobByIDResult{
-			Manifest: nil,
+		manifestJobID, err := workers.EnqueueManifestJobByID(&worker.ManifestJobByID{}, depsolveJobID)
+		if err != nil {
+			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+		}
+		kojiFilename := fmt.Sprintf(
+			"%s-%s-%s.%s%s",
+			name,
+			version,
+			release,
+			ir.arch.Name(),
+			splitExtension(ir.imageType.Filename()),
+		)
+		buildID, err := workers.EnqueueOSBuildKojiAsDependency(ir.arch.Name(), &worker.OSBuildKojiJob{
+			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)
+		if err != nil {
+			return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
 		}
+		kojiFilenames = append(kojiFilenames, kojiFilename)
+		buildIDs = append(buildIDs, buildID)
 
-		defer func() {
-			if jobResult.JobError != nil {
-				logrus.Errorf("Error in manifest job %v: %v", manifestJobID, jobResult.JobError.Reason)
-			}
+		ctx, cancel := context.WithTimeout(context.Background(), time.Minute*5)
+		go generateManifest(ctx, cancel, workers, depsolveJobID, manifestJobID, ir.imageType, ir.repositories, ir.imageOptions, manifestSeed, bp.Customizations)
+	}
+	id, err = workers.EnqueueKojiFinalize(&worker.KojiFinalizeJob{
+		Server:        server,
+		Name:          name,
+		Version:       version,
+		Release:       release,
+		KojiFilenames: kojiFilenames,
+		KojiDirectory: kojiDirectory,
+		TaskID:        taskID,
+		StartTime:     uint64(time.Now().Unix()),
+	}, initID, buildIDs)
+	if err != nil {
+		return id, HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+	}
 
-			result, err := json.Marshal(jobResult)
-			if err != nil {
-				logrus.Errorf("Error marshalling manifest job %v results: %v", manifestJobID, err)
-			}
+	return id, nil
+}
 
-			err = workers.FinishJob(token, result)
-			if err != nil {
-				logrus.Errorf("Error finishing manifest job: %v", err)
-			}
-		}()
+func generateManifest(ctx context.Context, cancel context.CancelFunc, workers *worker.Server, depsolveJobID uuid.UUID, manifestJobID uuid.UUID, imageType distro.ImageType, repos []rpmmd.RepoConfig, options distro.ImageOptions, seed int64, b *blueprint.Customizations) {
+	defer cancel()
 
-		if len(dynArgs) == 0 {
-			reason := "No dynamic arguments"
-			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorNoDynamicArgs, reason)
-			return
+	// wait until job is in a pending state
+	var token uuid.UUID
+	var dynArgs []json.RawMessage
+	var err error
+	for {
+		_, token, _, _, dynArgs, err = workers.RequestJobById(ctx, "", manifestJobID)
+		if err == jobqueue.ErrNotPending {
+			logrus.Debugf("Manifest job %v not pending, waiting for depsolve job to finish", manifestJobID)
+			time.Sleep(time.Millisecond * 50)
+			select {
+			case <-ctx.Done():
+				logrus.Warnf("Manifest job %v's dependencies took longer than 5 minutes to finish, returning to avoid dangling routines", manifestJobID)
+				break
+			default:
+				continue
+			}
 		}
-
-		var depsolveResults worker.DepsolveJobResult
-		err = json.Unmarshal(dynArgs[0], &depsolveResults)
 		if err != nil {
-			reason := "Error parsing dynamic arguments"
-			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, reason)
+			logrus.Errorf("Error requesting manifest job: %v", err)
 			return
 		}
+		break
+	}
 
-		_, _, err = workers.JobStatus(depsolveJobID, &depsolveResults)
-		if err != nil {
-			reason := "Error reading depsolve status"
-			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorReadingJobStatus, reason)
-			return
+	var jobResult *worker.ManifestJobByIDResult = &worker.ManifestJobByIDResult{
+		Manifest: nil,
+	}
+
+	defer func() {
+		if jobResult.JobError != nil {
+			logrus.Errorf("Error in manifest job %v: %v", manifestJobID, jobResult.JobError.Reason)
 		}
 
-		if jobErr := depsolveResults.JobError; jobErr != nil {
-			if jobErr.ID == clienterrors.ErrorDNFDepsolveError || jobErr.ID == clienterrors.ErrorDNFMarkingError {
-				jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDepsolveDependency, "Error in depsolve job dependency input, bad package set requested")
-				return
-			}
-			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDepsolveDependency, "Error in depsolve job dependency")
-			return
+		result, err := json.Marshal(jobResult)
+		if err != nil {
+			logrus.Errorf("Error marshalling manifest job %v results: %v", manifestJobID, err)
 		}
 
-		manifest, err := imageType.Manifest(b, options, repos, depsolveResults.PackageSpecs, seed)
+		err = workers.FinishJob(token, result)
 		if err != nil {
-			reason := "Error generating manifest"
-			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorManifestGeneration, reason)
+			logrus.Errorf("Error finishing manifest job: %v", err)
+		}
+	}()
+
+	if len(dynArgs) == 0 {
+		reason := "No dynamic arguments"
+		jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorNoDynamicArgs, reason)
+		return
+	}
+
+	var depsolveResults worker.DepsolveJobResult
+	err = json.Unmarshal(dynArgs[0], &depsolveResults)
+	if err != nil {
+		reason := "Error parsing dynamic arguments"
+		jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, reason)
+		return
+	}
+
+	_, _, err = workers.DepsolveJobStatus(depsolveJobID, &depsolveResults)
+	if err != nil {
+		reason := "Error reading depsolve status"
+		jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorReadingJobStatus, reason)
+		return
+	}
+
+	if jobErr := depsolveResults.JobError; jobErr != nil {
+		if jobErr.ID == clienterrors.ErrorDNFDepsolveError || jobErr.ID == clienterrors.ErrorDNFMarkingError {
+			jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDepsolveDependency, "Error in depsolve job dependency input, bad package set requested")
 			return
 		}
+		jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDepsolveDependency, "Error in depsolve job dependency")
+		return
+	}
 
-		jobResult.Manifest = manifest
-	}(h.server.workers, manifestJobID, blueprintCustoms, imageOptions, repositories, manifestSeed, depsolveJobID)
+	manifest, err := imageType.Manifest(b, options, repos, depsolveResults.PackageSpecs, seed)
+	if err != nil {
+		reason := "Error generating manifest"
+		jobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorManifestGeneration, reason)
+		return
+	}
 
-	return ctx.JSON(http.StatusCreated, &ComposeId{
-		ObjectReference: ObjectReference{
-			Href: "/api/image-builder-composer/v2/compose",
-			Id:   id.String(),
-			Kind: "ComposeId",
-		},
-		Id: id.String(),
-	})
+	jobResult.Manifest = manifest
 }
 
 func imageTypeFromApiImageType(it ImageTypes) string {
@@ -553,75 +719,128 @@ func (h *apiHandlers) GetComposeStatus(ctx echo.Context, id string) error {
 		return HTTPError(ErrorInvalidComposeId)
 	}
 
-	var result worker.OSBuildJobResult
-	status, _, err := h.server.workers.JobStatus(jobId, &result)
+	jobType, err := h.server.workers.JobType(jobId)
 	if err != nil {
 		return HTTPError(ErrorComposeNotFound)
 	}
 
-	var us *UploadStatus
-	if result.TargetResults != nil {
-		// Only single upload target is allowed, therefore only a single upload target result is allowed as well
-		if len(result.TargetResults) != 1 {
-			return HTTPError(ErrorSeveralUploadTargets)
+	if jobType == "osbuild" {
+		var result worker.OSBuildJobResult
+		status, _, err := h.server.workers.OSBuildJobStatus(jobId, &result)
+		if err != nil {
+			return HTTPError(ErrorMalformedOSBuildJobResult)
 		}
-		tr := *result.TargetResults[0]
-
-		var uploadType UploadTypes
-		var uploadOptions interface{}
 
-		switch tr.Name {
-		case "org.osbuild.aws":
-			uploadType = UploadTypesAws
-			awsOptions := tr.Options.(*target.AWSTargetResultOptions)
-			uploadOptions = AWSEC2UploadStatus{
-				Ami:    awsOptions.Ami,
-				Region: awsOptions.Region,
-			}
-		case "org.osbuild.aws.s3":
-			uploadType = UploadTypesAwsS3
-			awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
-			uploadOptions = AWSS3UploadStatus{
-				Url: awsOptions.URL,
+		var us *UploadStatus
+		if result.TargetResults != nil {
+			// Only single upload target is allowed, therefore only a single upload target result is allowed as well
+			if len(result.TargetResults) != 1 {
+				return HTTPError(ErrorSeveralUploadTargets)
 			}
-		case "org.osbuild.gcp":
-			uploadType = UploadTypesGcp
-			gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
-			uploadOptions = GCPUploadStatus{
-				ImageName: gcpOptions.ImageName,
-				ProjectId: gcpOptions.ProjectID,
+			tr := *result.TargetResults[0]
+
+			var uploadType UploadTypes
+			var uploadOptions interface{}
+
+			switch tr.Name {
+			case "org.osbuild.aws":
+				uploadType = UploadTypesAws
+				awsOptions := tr.Options.(*target.AWSTargetResultOptions)
+				uploadOptions = AWSEC2UploadStatus{
+					Ami:    awsOptions.Ami,
+					Region: awsOptions.Region,
+				}
+			case "org.osbuild.aws.s3":
+				uploadType = UploadTypesAwsS3
+				awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
+				uploadOptions = AWSS3UploadStatus{
+					Url: awsOptions.URL,
+				}
+			case "org.osbuild.gcp":
+				uploadType = UploadTypesGcp
+				gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
+				uploadOptions = GCPUploadStatus{
+					ImageName: gcpOptions.ImageName,
+					ProjectId: gcpOptions.ProjectID,
+				}
+			case "org.osbuild.azure.image":
+				uploadType = UploadTypesAzure
+				gcpOptions := tr.Options.(*target.AzureImageTargetResultOptions)
+				uploadOptions = AzureUploadStatus{
+					ImageName: gcpOptions.ImageName,
+				}
+			default:
+				return HTTPError(ErrorUnknownUploadTarget)
 			}
-		case "org.osbuild.azure.image":
-			uploadType = UploadTypesAzure
-			gcpOptions := tr.Options.(*target.AzureImageTargetResultOptions)
-			uploadOptions = AzureUploadStatus{
-				ImageName: gcpOptions.ImageName,
+
+			us = &UploadStatus{
+				Status:  UploadStatusValue(result.UploadStatus),
+				Type:    uploadType,
+				Options: uploadOptions,
 			}
-		default:
-			return HTTPError(ErrorUnknownUploadTarget)
 		}
 
-		us = &UploadStatus{
-			Status:  UploadStatusValue(result.UploadStatus),
-			Type:    uploadType,
-			Options: uploadOptions,
+		return ctx.JSON(http.StatusOK, ComposeStatus{
+			ObjectReference: ObjectReference{
+				Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId),
+				Id:   jobId.String(),
+				Kind: "ComposeStatus",
+			},
+			Status: composeStatusFromOSBuildJobStatus(status, &result),
+			ImageStatus: ImageStatus{
+				Status:       imageStatusFromOSBuildJobStatus(status, &result),
+				UploadStatus: us,
+			},
+		})
+	} else if jobType == "koji-finalize" {
+		var result worker.KojiFinalizeJobResult
+		finalizeStatus, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &result)
+		if err != nil {
+			return HTTPError(ErrorMalformedOSBuildJobResult)
 		}
+		if len(deps) < 2 {
+			return HTTPError(ErrorUnexpectedNumberOfImageBuilds)
+		}
+		var initResult worker.KojiInitJobResult
+		_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
+		if err != nil {
+			return HTTPError(ErrorMalformedOSBuildJobResult)
+		}
+		var buildJobResults []worker.OSBuildKojiJobResult
+		var buildJobStatuses []ImageStatus
+		for i := 1; i < len(deps); i++ {
+			var buildJobResult worker.OSBuildKojiJobResult
+			buildJobStatus, _, err := h.server.workers.OSBuildKojiJobStatus(deps[i], &buildJobResult)
+			if err != nil {
+				return HTTPError(ErrorMalformedOSBuildJobResult)
+			}
+			buildJobResults = append(buildJobResults, buildJobResult)
+			buildJobStatuses = append(buildJobStatuses, ImageStatus{
+				Status: imageStatusFromKojiJobStatus(buildJobStatus, &initResult, &buildJobResult),
+			})
+		}
+		response := ComposeStatus{
+			ObjectReference: ObjectReference{
+				Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId),
+				Id:   jobId.String(),
+				Kind: "ComposeStatus",
+			},
+			Status:        composeStatusFromKojiJobStatus(finalizeStatus, &initResult, buildJobResults, &result),
+			ImageStatus:   buildJobStatuses[0], // backwards compatibility
+			ImageStatuses: &buildJobStatuses,
+			KojiStatus:    &KojiStatus{},
+		}
+		buildID := int(initResult.BuildID)
+		if buildID != 0 {
+			response.KojiStatus.BuildId = &buildID
+		}
+		return ctx.JSON(http.StatusOK, response)
+	} else {
+		return HTTPError(ErrorInvalidJobType)
 	}
-
-	return ctx.JSON(http.StatusOK, ComposeStatus{
-		ObjectReference: ObjectReference{
-			Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId),
-			Id:   jobId.String(),
-			Kind: "ComposeStatus",
-		},
-		ImageStatus: ImageStatus{
-			Status:       composeStatusFromJobStatus(status, &result),
-			UploadStatus: us,
-		},
-	})
 }
 
-func composeStatusFromJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ImageStatusValue {
+func imageStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ImageStatusValue {
 	if js.Canceled {
 		return ImageStatusValueFailure
 	}
@@ -643,6 +862,72 @@ func composeStatusFromJobStatus(js *worker.JobStatus, result *worker.OSBuildJobR
 	return ImageStatusValueFailure
 }
 
+func imageStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResult *worker.OSBuildKojiJobResult) ImageStatusValue {
+	if js.Canceled {
+		return ImageStatusValueFailure
+	}
+
+	if initResult.JobError != nil {
+		return ImageStatusValueFailure
+	}
+
+	if js.Started.IsZero() {
+		return ImageStatusValuePending
+	}
+
+	if js.Finished.IsZero() {
+		return ImageStatusValueBuilding
+	}
+
+	if buildResult.JobError != nil {
+		return ImageStatusValueFailure
+	}
+
+	return ImageStatusValueSuccess
+}
+
+func composeStatusFromOSBuildJobStatus(js *worker.JobStatus, result *worker.OSBuildJobResult) ComposeStatusValue {
+	if js.Canceled {
+		return ComposeStatusValueFailure
+	}
+
+	if js.Finished.IsZero() {
+		return ComposeStatusValuePending
+	}
+
+	if result.Success {
+		return ComposeStatusValueSuccess
+	}
+
+	return ComposeStatusValueFailure
+}
+
+func composeStatusFromKojiJobStatus(js *worker.JobStatus, initResult *worker.KojiInitJobResult, buildResults []worker.OSBuildKojiJobResult, result *worker.KojiFinalizeJobResult) ComposeStatusValue {
+	if js.Canceled {
+		return ComposeStatusValueFailure
+	}
+
+	if js.Finished.IsZero() {
+		return ComposeStatusValuePending
+	}
+
+	if initResult.JobError != nil {
+		return ComposeStatusValueFailure
+	}
+
+	for _, buildResult := range buildResults {
+		if buildResult.JobError != nil {
+			return ComposeStatusValueFailure
+		}
+	}
+
+	if result.JobError != nil {
+		return ComposeStatusValueFailure
+	}
+
+	return ComposeStatusValueSuccess
+}
+
 // ComposeMetadata handles a /composes/{id}/metadata GET request
 func (h *apiHandlers) GetComposeMetadata(ctx echo.Context, id string) error {
 	jobId, err := uuid.Parse(id)
@@ -650,14 +935,24 @@ func (h *apiHandlers) GetComposeMetadata(ctx echo.Context, id string) error {
 		return HTTPError(ErrorInvalidComposeId)
 	}
 
+	jobType, err := h.server.workers.JobType(jobId)
+	if err != nil {
+		return HTTPError(ErrorComposeNotFound)
+	}
+
+	// TODO: support koji builds
+	if jobType != "osbuild" {
+		return HTTPError(ErrorInvalidJobType)
+	}
+
 	var result worker.OSBuildJobResult
-	status, _, err := h.server.workers.JobStatus(jobId, &result)
+	status, _, err := h.server.workers.OSBuildJobStatus(jobId, &result)
 	if err != nil {
 		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
 	}
 
 	var job worker.OSBuildJob
-	if _, _, _, err = h.server.workers.Job(jobId, &job); err != nil {
+	if err = h.server.workers.OSBuildJob(jobId, &job); err != nil {
 		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
 	}
 
@@ -743,3 +1038,124 @@ func stagesToPackageMetadata(stages []osbuild.RPMStageMetadata) []PackageMetadat
 	}
 	return packages
 }
+
+// Get logs for a compose
+func (h *apiHandlers) GetComposeLogs(ctx echo.Context, id string) error {
+	jobId, err := uuid.Parse(id)
+	if err != nil {
+		return HTTPError(ErrorInvalidComposeId)
+	}
+
+	jobType, err := h.server.workers.JobType(jobId)
+	if err != nil {
+		return HTTPError(ErrorComposeNotFound)
+	}
+
+	// TODO: support non-koji builds
+	if jobType != "koji-finalize" {
+		return HTTPError(ErrorInvalidJobType)
+	}
+
+	var finalizeResult worker.KojiFinalizeJobResult
+	_, deps, err := h.server.workers.KojiFinalizeJobStatus(jobId, &finalizeResult)
+	if err != nil {
+		return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+	}
+
+	var initResult worker.KojiInitJobResult
+	_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
+	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)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
+		buildResultBlobs = append(buildResultBlobs, buildResult)
+	}
+
+	// Return the OSBuildJobResults as-is for now. The contents of ImageLogs
+	// 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,
+	}
+
+	return ctx.JSON(http.StatusOK, resp)
+}
+
+// GetComposeIdManifests returns the Manifests for a given Compose (one for each image).
+func (h *apiHandlers) GetComposeManifests(ctx echo.Context, id string) error {
+	jobId, err := uuid.Parse(id)
+	if err != nil {
+		return HTTPError(ErrorInvalidComposeId)
+	}
+
+	jobType, err := h.server.workers.JobType(jobId)
+	if err != nil {
+		return HTTPError(ErrorComposeNotFound)
+	}
+
+	// TODO: support non-koji builds
+	if jobType != "koji-finalize" {
+		return HTTPError(ErrorInvalidJobType)
+	}
+
+	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)
+		if err != nil {
+			return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+		}
+		var manifest distro.Manifest
+		if len(buildJob.Manifest) == 0 {
+			manifest = buildJob.Manifest
+		} else {
+			_, deps, err := h.server.workers.OSBuildKojiJobStatus(id, nil)
+			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)
+			if err != nil {
+				return HTTPErrorWithInternal(ErrorComposeNotFound, err)
+			}
+			manifest = manifestResult.Manifest
+		}
+		manifestBlobs = append(manifestBlobs, manifest)
+	}
+
+	resp := &ComposeManifests{
+		ObjectReference: ObjectReference{
+			Href: fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/manifests", jobId),
+			Id:   jobId.String(),
+			Kind: "ComposeManifests",
+		},
+		Manifests: manifestBlobs,
+	}
+
+	return ctx.JSON(http.StatusOK, resp)
+}
diff --git a/internal/distro/rhel90/package_sets.go b/internal/distro/rhel90/package_sets.go
index ec041b4b7..bb0beecd7 100644
--- a/internal/distro/rhel90/package_sets.go
+++ b/internal/distro/rhel90/package_sets.go
@@ -115,12 +115,10 @@ func anacondaBootPackageSet(t *imageType) rpmmd.PackageSet {
 		ps = ps.Append(efiCommon)
 		ps = ps.Append(rpmmd.PackageSet{
 			Include: []string{
-				"grub2-efi-ia32-cdboot",
 				"grub2-efi-x64",
 				"grub2-efi-x64-cdboot",
 				"grub2-pc",
 				"grub2-pc-modules",
-				"shim-ia32",
 				"shim-x64",
 				"syslinux",
 				"syslinux-nonlinux",
@@ -974,7 +972,6 @@ func anacondaPackageSet(t *imageType) rpmmd.PackageSet {
 			"glibc-all-langpacks",
 			"gnome-kiosk",
 			"google-noto-sans-cjk-ttc-fonts",
-			"grub2-efi-ia32-cdboot",
 			"grub2-efi-x64-cdboot",
 			"grub2-tools",
 			"grub2-tools-efi",
@@ -1054,7 +1051,6 @@ func anacondaPackageSet(t *imageType) rpmmd.PackageSet {
 			"rsyslog",
 			"selinux-policy-targeted",
 			"sg3_utils",
-			"shim-ia32",
 			"shim-x64",
 			"sil-abyssinica-fonts",
 			"sil-padauk-fonts",
diff --git a/internal/distro/rhel90/stage_options.go b/internal/distro/rhel90/stage_options.go
index 3f86df7dd..3774b15d4 100644
--- a/internal/distro/rhel90/stage_options.go
+++ b/internal/distro/rhel90/stage_options.go
@@ -273,7 +273,7 @@ func bootISOMonoStageOptions(kernelVer, arch, vendor, product, osVersion, isolab
 	var architectures []string
 
 	if arch == distro.X86_64ArchName {
-		architectures = []string{"IA32", "X64"}
+		architectures = []string{"X64"}
 	} else if arch == distro.Aarch64ArchName {
 		architectures = []string{"AA64"}
 	} else {
@@ -311,7 +311,7 @@ func grubISOStageOptions(installDevice, kernelVer, arch, vendor, product, osVers
 	var architectures []string
 
 	if arch == "x86_64" {
-		architectures = []string{"IA32", "X64"}
+		architectures = []string{"X64"}
 	} else if arch == "aarch64" {
 		architectures = []string{"AA64"}
 	} else {
diff --git a/internal/jobqueue/dbjobqueue/dbjobqueue.go b/internal/jobqueue/dbjobqueue/dbjobqueue.go
index e13769e37..246798bec 100644
--- a/internal/jobqueue/dbjobqueue/dbjobqueue.go
+++ b/internal/jobqueue/dbjobqueue/dbjobqueue.go
@@ -67,7 +67,7 @@ const (
 		FROM jobs
 		WHERE id = $1`
 	sqlQueryJobStatus = `
-		SELECT result, queued_at, started_at, finished_at, canceled
+		SELECT type, result, queued_at, started_at, finished_at, canceled
 		FROM jobs
 		WHERE id = $1`
 	sqlQueryRunningId = `
@@ -396,7 +396,7 @@ func (q *DBJobQueue) CancelJob(id uuid.UUID) error {
 	return nil
 }
 
-func (q *DBJobQueue) JobStatus(id uuid.UUID) (result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error) {
+func (q *DBJobQueue) JobStatus(id uuid.UUID) (jobType string, result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error) {
 	conn, err := q.pool.Acquire(context.Background())
 	if err != nil {
 		return
@@ -406,7 +406,7 @@ func (q *DBJobQueue) JobStatus(id uuid.UUID) (result json.RawMessage, queued, st
 	// Use double pointers for timestamps because they might be NULL, which would result in *time.Time == nil
 	var sp, fp *time.Time
 	var rp pgtype.JSON
-	err = conn.QueryRow(context.Background(), sqlQueryJobStatus, id).Scan(&rp, &queued, &sp, &fp, &canceled)
+	err = conn.QueryRow(context.Background(), sqlQueryJobStatus, id).Scan(&jobType, &rp, &queued, &sp, &fp, &canceled)
 	if err != nil {
 		return
 	}
diff --git a/internal/jobqueue/fsjobqueue/fsjobqueue.go b/internal/jobqueue/fsjobqueue/fsjobqueue.go
index f3389ee11..cd900e028 100644
--- a/internal/jobqueue/fsjobqueue/fsjobqueue.go
+++ b/internal/jobqueue/fsjobqueue/fsjobqueue.go
@@ -343,12 +343,13 @@ func (q *fsJobQueue) CancelJob(id uuid.UUID) error {
 	return nil
 }
 
-func (q *fsJobQueue) JobStatus(id uuid.UUID) (result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error) {
+func (q *fsJobQueue) JobStatus(id uuid.UUID) (jobType string, result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error) {
 	j, err := q.readJob(id)
 	if err != nil {
 		return
 	}
 
+	jobType = j.Type
 	result = j.Result
 	queued = j.QueuedAt
 	started = j.StartedAt
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
index 047ed7857..02cb98066 100644
--- a/internal/jobqueue/jobqueue.go
+++ b/internal/jobqueue/jobqueue.go
@@ -64,7 +64,7 @@ type JobQueue interface {
 	// finished, respectively.
 	//
 	// Lastly, the IDs of the jobs dependencies are returned.
-	JobStatus(id uuid.UUID) (result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error)
+	JobStatus(id uuid.UUID) (jobType string, result json.RawMessage, queued, started, finished time.Time, canceled bool, deps []uuid.UUID, err error)
 
 	// Job returns all the parameters that define a job (everything provided during Enqueue).
 	Job(id uuid.UUID) (jobType string, args json.RawMessage, dependencies []uuid.UUID, err error)
diff --git a/internal/kojiapi/server.go b/internal/kojiapi/server.go
index a8e4e3ff3..e73f8866c 100644
--- a/internal/kojiapi/server.go
+++ b/internal/kojiapi/server.go
@@ -207,7 +207,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	// changes.
 	var initResult worker.KojiInitJobResult
 	for {
-		status, _, err := h.server.workers.JobStatus(initID, &initResult)
+		status, _, err := h.server.workers.KojiInitJobStatus(initID, &initResult)
 		if err != nil {
 			panic(err)
 		}
@@ -306,23 +306,14 @@ func (h *apiHandlers) GetComposeId(ctx echo.Context, idstr string) error {
 		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err))
 	}
 
-	// Make sure id exists and matches a FinalizeJob
-	if _, _, err := h.getFinalizeJob(id); err != nil {
-		return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s not found: %s", idstr, err))
-	}
-
 	var finalizeResult worker.KojiFinalizeJobResult
-	finalizeStatus, deps, err := h.server.workers.JobStatus(id, &finalizeResult)
+	finalizeStatus, deps, err := h.server.workers.KojiFinalizeJobStatus(id, &finalizeResult)
 	if err != nil {
 		return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s not found: %s", idstr, err))
 	}
 
-	// Make sure deps[0] matches a KojiInitJob
-	if _, err := h.getInitJob(deps[0]); err != nil {
-		panic(err)
-	}
 	var initResult worker.KojiInitJobResult
-	_, _, err = h.server.workers.JobStatus(deps[0], &initResult)
+	_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
 	if err != nil {
 		// this is a programming error
 		panic(err)
@@ -331,12 +322,8 @@ func (h *apiHandlers) GetComposeId(ctx echo.Context, idstr string) error {
 	var buildResults []worker.OSBuildKojiJobResult
 	var imageStatuses []api.ImageStatus
 	for i := 1; i < len(deps); i++ {
-		// Make sure deps[i] matches an OSBuildKojiJob
-		if _, _, err := h.getBuildJob(deps[i]); err != nil {
-			panic(err)
-		}
 		var buildResult worker.OSBuildKojiJobResult
-		jobStatus, _, err := h.server.workers.JobStatus(deps[i], &buildResult)
+		jobStatus, _, err := h.server.workers.OSBuildKojiJobStatus(deps[i], &buildResult)
 		if err != nil {
 			// this is a programming error
 			panic(err)
@@ -372,24 +359,14 @@ func (h *apiHandlers) GetComposeIdLogs(ctx echo.Context, idstr string) error {
 		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err))
 	}
 
-	// Make sure id exists and matches a FinalizeJob
-	if _, _, err := h.getFinalizeJob(id); err != nil {
-		return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s not found: %s", idstr, err))
-	}
-
 	var finalizeResult worker.KojiFinalizeJobResult
-	_, deps, err := h.server.workers.JobStatus(id, &finalizeResult)
+	_, deps, err := h.server.workers.KojiFinalizeJobStatus(id, &finalizeResult)
 	if err != nil {
 		return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s not found: %s", idstr, err))
 	}
 
-	// Make sure deps[0] matches a KojiInitJob
-	if _, err := h.getInitJob(deps[0]); err != nil {
-		panic(err)
-	}
-
 	var initResult worker.KojiInitJobResult
-	_, _, err = h.server.workers.JobStatus(deps[0], &initResult)
+	_, _, err = h.server.workers.KojiInitJobStatus(deps[0], &initResult)
 	if err != nil {
 		// This is a programming error.
 		panic(err)
@@ -397,12 +374,8 @@ func (h *apiHandlers) GetComposeIdLogs(ctx echo.Context, idstr string) error {
 
 	var buildResults []interface{}
 	for i := 1; i < len(deps); i++ {
-		// Make sure deps[i] matches an OSBuildKojiJob
-		if _, _, err := h.getBuildJob(deps[i]); err != nil {
-			panic(err)
-		}
 		var buildResult worker.OSBuildKojiJobResult
-		_, _, err = h.server.workers.JobStatus(deps[i], &buildResult)
+		_, _, err = h.server.workers.OSBuildKojiJobStatus(deps[i], &buildResult)
 		if err != nil {
 			// This is a programming error.
 			panic(err)
@@ -422,53 +395,6 @@ func (h *apiHandlers) GetComposeIdLogs(ctx echo.Context, idstr string) error {
 	return ctx.JSON(http.StatusOK, response)
 }
 
-// getFinalizeJob retrieves a KojiFinalizeJob and the IDs of its dependencies
-// from the job queue given its ID.  It returns an error if the ID matches a
-// job of a different type.
-func (h *apiHandlers) getFinalizeJob(id uuid.UUID) (*worker.KojiFinalizeJob, []uuid.UUID, error) {
-	job := new(worker.KojiFinalizeJob)
-	jobType, _, deps, err := h.server.workers.Job(id, job)
-	if err != nil {
-		return nil, nil, err
-	}
-	expType := "koji-finalize"
-	if jobType != expType {
-		return nil, nil, fmt.Errorf("expected %q, found %q job instead", expType, jobType)
-	}
-	return job, deps, err
-}
-
-// getInitJob retrieves a KojiInitJob from the job queue given its ID.
-func (h *apiHandlers) getInitJob(id uuid.UUID) (*worker.KojiInitJob, error) {
-	// It returns an error if the ID matches a job of a different type.
-	job := new(worker.KojiInitJob)
-	jobType, _, _, err := h.server.workers.Job(id, job)
-	if err != nil {
-		return nil, err
-	}
-	expType := "koji-init"
-	if jobType != expType {
-		return nil, fmt.Errorf("expected %q, found %q job instead", expType, jobType)
-	}
-	return job, err
-}
-
-// getBuildJob retrieves a OSBuildKojiJob and the IDs of its dependencies from
-// the job queue given its ID.  It returns an error if the ID matches a job of
-// a different type.
-func (h *apiHandlers) getBuildJob(id uuid.UUID) (*worker.OSBuildKojiJob, []uuid.UUID, error) {
-	job := new(worker.OSBuildKojiJob)
-	jobType, _, deps, err := h.server.workers.Job(id, job)
-	if err != nil {
-		return nil, nil, err
-	}
-	expType := "osbuild-koji"
-	if !strings.HasPrefix(jobType, expType) { // Build jobs get automatic arch suffix: Check prefix
-		return nil, nil, fmt.Errorf("expected %q, found %q job instead", expType, jobType)
-	}
-	return job, deps, nil
-}
-
 // GetComposeIdManifests returns the Manifests for a given Compose (one for each image).
 func (h *apiHandlers) GetComposeIdManifests(ctx echo.Context, idstr string) error {
 	id, err := uuid.Parse(idstr)
@@ -476,19 +402,20 @@ func (h *apiHandlers) GetComposeIdManifests(ctx echo.Context, idstr string) erro
 		return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter id: %s", err))
 	}
 
-	_, deps, err := h.getFinalizeJob(id)
+	var finalizeResult worker.KojiFinalizeJobResult
+	_, deps, err := h.server.workers.KojiFinalizeJobStatus(id, &finalizeResult)
 	if err != nil {
 		return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s not found: %s", idstr, err))
 	}
 
-	manifests := make([]distro.Manifest, len(deps)-1)
-	for i, id := range deps[1:] {
-		buildJob, _, err := h.getBuildJob(id)
+	var manifests []distro.Manifest
+	for _, id := range deps[1:] {
+		var buildJob worker.OSBuildKojiJob
+		err = h.server.workers.OSBuildKojiJob(id, &buildJob)
 		if err != nil {
-			// This is a programming error.
-			panic(err)
+			return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Job %s could not be deserialized: %s", idstr, err))
 		}
-		manifests[i] = buildJob.Manifest
+		manifests = append(manifests, buildJob.Manifest)
 	}
 
 	return ctx.JSON(http.StatusOK, manifests)
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index df3b33b29..92b84b012 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -345,8 +345,7 @@ func (api *API) getComposeStatus(compose store.Compose) *composeStatus {
 
 	// All jobs are "osbuild" jobs.
 	var result worker.OSBuildJobResult
-
-	jobStatus, _, err := api.workers.JobStatus(jobId, &result)
+	jobStatus, _, err := api.workers.OSBuildJobStatus(jobId, &result)
 	if err != nil {
 		panic(err)
 	}
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 3cc7d09d3..cdeb34d50 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -52,7 +52,7 @@ type KojiInitJobResult struct {
 }
 
 type OSBuildKojiJob struct {
-	Manifest      distro.Manifest `json:"manifest"`
+	Manifest      distro.Manifest `json:"manifest,omitempty"`
 	ImageName     string          `json:"image_name"`
 	Exports       []string        `json:"exports"`
 	PipelineNames *PipelineNames  `json:"pipeline_names,omitempty"`
diff --git a/internal/worker/server.go b/internal/worker/server.go
index b33955e13..aa9a84ce4 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -12,6 +12,7 @@ import (
 	"os"
 	"path"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/google/uuid"
@@ -103,6 +104,10 @@ func (s *Server) EnqueueOSBuildKoji(arch string, job *OSBuildKojiJob, initID uui
 	return s.jobs.Enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID})
 }
 
+func (s *Server) EnqueueOSBuildKojiAsDependency(arch string, job *OSBuildKojiJob, manifestID, initID uuid.UUID) (uuid.UUID, error) {
+	return s.jobs.Enqueue("osbuild-koji:"+arch, job, []uuid.UUID{initID, manifestID})
+}
+
 func (s *Server) EnqueueKojiInit(job *KojiInitJob) (uuid.UUID, error) {
 	return s.jobs.Enqueue("koji-init", job, nil)
 }
@@ -119,60 +124,156 @@ func (s *Server) EnqueueManifestJobByID(job *ManifestJobByID, parent uuid.UUID)
 	return s.jobs.Enqueue("manifest-id-only", job, []uuid.UUID{parent})
 }
 
-func (s *Server) JobStatus(id uuid.UUID, result interface{}) (*JobStatus, []uuid.UUID, error) {
-	rawResult, queued, started, finished, canceled, deps, err := s.jobs.JobStatus(id)
+func (s *Server) OSBuildJobStatus(id uuid.UUID, result *OSBuildJobResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
 	if err != nil {
 		return nil, nil, err
 	}
 
-	if !finished.IsZero() && !canceled {
-		err = json.Unmarshal(rawResult, result)
-		if err != nil {
-			return nil, nil, fmt.Errorf("error unmarshaling result for job '%s': %v", id, 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)
 	}
 
-	switch r := result.(type) {
-	case *KojiInitJobResult:
-		if r.JobError == nil && r.KojiError != "" {
-			r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, r.KojiError)
-		}
-	case *KojiFinalizeJobResult:
-		if r.JobError == nil && r.KojiError != "" {
-			r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, r.KojiError)
-		}
-	case *OSBuildKojiJobResult:
-		if r.JobError == nil && r.KojiError != "" {
-			r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, r.KojiError)
+	if result.JobError == nil {
+		if result.OSBuildOutput == nil {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorBuildJob, "osbuild build failed")
+		} else if len(result.OSBuildOutput.Error) > 0 {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, string(result.OSBuildOutput.Error))
+		} else if len(result.TargetErrors) > 0 {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, result.TargetErrors[0])
 		}
-	case *ManifestJobByIDResult:
-		if r.JobError == nil && r.Error != "" {
-			r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, r.Error)
-		}
-	case *DepsolveJobResult:
-		if r.JobError == nil && r.Error != "" {
-			if r.ErrorType == DepsolveErrorType {
-				r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDNFDepsolveError, r.Error)
-			} else {
-				r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorRPMMDError, r.Error)
-			}
+	}
+	// For backwards compatibility: OSBuildJobResult didn't use to have a
+	// top-level `Success` flag. Override it here by looking into the job.
+	if !result.Success && result.OSBuildOutput != nil {
+		result.Success = result.OSBuildOutput.Success && result.JobError == nil
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) OSBuildKojiJobStatus(id uuid.UUID, result *OSBuildKojiJobResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		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 result.JobError == nil {
+		if result.OSBuildOutput == nil {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorBuildJob, "osbuild build failed")
+		} else if len(result.OSBuildOutput.Error) > 0 {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, string(result.OSBuildOutput.Error))
+		} else if result.KojiError != "" {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, result.KojiError)
 		}
-	case *OSBuildJobResult:
-		if r.JobError == nil && len(r.TargetErrors) > 0 || (r.OSBuildOutput != nil && len(r.OSBuildOutput.Error) > 0) {
-			if r.OSBuildOutput != nil && len(r.OSBuildOutput.Error) > 0 {
-				r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, string(r.OSBuildOutput.Error))
-			} else if len(r.TargetErrors) > 0 {
-				r.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, r.TargetErrors[0])
-			}
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) KojiInitJobStatus(id uuid.UUID, result *KojiInitJobResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if jobType != "koji-init" {
+		return nil, nil, fmt.Errorf("expected \"koji-init\", found %q job instead", jobType)
+	}
+
+	if result.JobError == nil && result.KojiError != "" {
+		result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, result.KojiError)
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) KojiFinalizeJobStatus(id uuid.UUID, result *KojiFinalizeJobResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if jobType != "koji-finalize" {
+		return nil, nil, fmt.Errorf("expected \"koji-finalize\", found %q job instead", jobType)
+	}
+
+	if result.JobError == nil && result.KojiError != "" {
+		result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, result.KojiError)
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) DepsolveJobStatus(id uuid.UUID, result *DepsolveJobResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if jobType != "depsolve" {
+		return nil, nil, fmt.Errorf("expected \"depsolve\", found %q job instead", jobType)
+	}
+
+	if result.JobError == nil && result.Error != "" {
+		if result.ErrorType == DepsolveErrorType {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorDNFDepsolveError, result.Error)
+		} else {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorRPMMDError, result.Error)
 		}
-		// For backwards compatibility: OSBuildJobResult didn't use to have a
-		// top-level `Success` flag. Override it here by looking into the job.
-		if !r.Success && r.OSBuildOutput != nil {
-			r.Success = r.OSBuildOutput.Success && r.JobError == nil
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) ManifestByIdJobStatus(id uuid.UUID, result *ManifestJobByIDResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if jobType != "manifest-by-id" {
+		return nil, nil, fmt.Errorf("expected \"koji-init\", found %q job instead", jobType)
+	}
+
+	if result.JobError == nil && result.Error != "" {
+		result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorOldResultCompatible, result.Error)
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) ManifestJobStatus(id uuid.UUID, result *ManifestJobByIDResult) (*JobStatus, []uuid.UUID, error) {
+	jobType, status, deps, err := s.jobStatus(id, result)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	if jobType != "manifest-job-by-id" {
+		return nil, nil, fmt.Errorf("expected \"manifest-job-by-id\", found %q job instead", jobType)
+	}
+
+	return status, deps, nil
+}
+
+func (s *Server) jobStatus(id uuid.UUID, result interface{}) (string, *JobStatus, []uuid.UUID, error) {
+	jobType, rawResult, queued, started, finished, canceled, deps, err := s.jobs.JobStatus(id)
+	if err != nil {
+		return "", nil, nil, err
+	}
+
+	if result != nil && !finished.IsZero() && !canceled {
+		err = json.Unmarshal(rawResult, result)
+		if err != nil {
+			return "", nil, nil, fmt.Errorf("error unmarshaling result for job '%s': %v", id, err)
 		}
 	}
 
-	return &JobStatus{
+	return jobType, &JobStatus{
 		Queued:   queued,
 		Started:  started,
 		Finished: finished,
@@ -180,20 +281,48 @@ func (s *Server) JobStatus(id uuid.UUID, result interface{}) (*JobStatus, []uuid
 	}, deps, nil
 }
 
-// Job provides access to all the parameters of a job.
-func (s *Server) Job(id uuid.UUID, job interface{}) (string, json.RawMessage, []uuid.UUID, error) {
-	jobType, rawArgs, deps, err := s.jobs.Job(id)
+// OSBuildJob returns the parameters of an OSBuildJob
+func (s *Server) OSBuildJob(id uuid.UUID, job *OSBuildJob) error {
+	jobType, rawArgs, _, err := s.jobs.Job(id)
 	if err != nil {
-		return "", nil, nil, err
+		return err
 	}
 
-	if job != nil {
-		if err := json.Unmarshal(rawArgs, job); err != nil {
-			return "", nil, nil, fmt.Errorf("error unmarshaling arguments for job '%s': %v", id, 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)
 	}
 
-	return jobType, rawArgs, deps, nil
+	if err := json.Unmarshal(rawArgs, job); err != nil {
+		return fmt.Errorf("error unmarshaling arguments for job '%s': %v", id, err)
+	}
+
+	return nil
+}
+
+// OSBuildKojiJob returns the parameters of an OSBuildKojiJob
+func (s *Server) OSBuildKojiJob(id uuid.UUID, job *OSBuildKojiJob) error {
+	jobType, rawArgs, _, err := s.jobs.Job(id)
+	if err != nil {
+		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 err := json.Unmarshal(rawArgs, job); err != nil {
+		return fmt.Errorf("error unmarshaling arguments for job '%s': %v", id, err)
+	}
+
+	return nil
+}
+
+// JobType returns the type of the job
+func (s *Server) JobType(id uuid.UUID) (string, error) {
+	jobType, _, _, err := s.jobs.Job(id)
+	// the architecture is internally encdode in the job type, but hide that
+	// from this API
+	return strings.Split(jobType, ":")[0], err
 }
 
 func (s *Server) Cancel(id uuid.UUID) error {
@@ -207,7 +336,7 @@ func (s *Server) JobArtifact(id uuid.UUID, name string) (io.Reader, int64, error
 		return nil, 0, errors.New("Artifacts not enabled")
 	}
 
-	status, _, err := s.JobStatus(id, &json.RawMessage{})
+	_, status, _, err := s.jobStatus(id, nil)
 	if err != nil {
 		return nil, 0, err
 	}
@@ -236,7 +365,7 @@ func (s *Server) DeleteArtifacts(id uuid.UUID) error {
 		return errors.New("Artifacts not enabled")
 	}
 
-	status, _, err := s.JobStatus(id, &json.RawMessage{})
+	_, status, _, err := s.jobStatus(id, nil)
 	if err != nil {
 		return err
 	}
@@ -291,7 +420,8 @@ func (s *Server) requestJob(ctx context.Context, arch string, jobTypes []string,
 	}
 
 	for _, depID := range depIDs {
-		result, _, _, _, _, _, _ := s.jobs.JobStatus(depID)
+		// TODO: include type of arguments
+		_, result, _, _, _, _, _, _ := s.jobs.JobStatus(depID)
 		dynamicArgs = append(dynamicArgs, result)
 	}
 
@@ -332,12 +462,6 @@ func (s *Server) FinishJob(token uuid.UUID, result json.RawMessage) error {
 		}
 	}
 
-	var jobResult OSBuildJobResult
-	_, _, err = s.JobStatus(jobId, &jobResult)
-	if err != nil {
-		return fmt.Errorf("error finding job status: %v", err)
-	}
-
 	// Move artifacts from the temporary location to the final job
 	// location. Log any errors, but do not treat them as fatal. The job is
 	// already finished.
@@ -466,7 +590,7 @@ func (h *apiHandlers) GetJob(ctx echo.Context, tokenstr string) error {
 
 	h.server.jobs.RefreshHeartbeat(token)
 
-	status, _, err := h.server.JobStatus(jobId, &json.RawMessage{})
+	_, status, _, err := h.server.jobStatus(jobId, nil)
 	if err != nil {
 		return api.HTTPErrorWithInternal(api.ErrorRetrievingJobStatus, err)
 	}
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index 414ae4ed5..06bf44a01 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -220,6 +220,7 @@ install -m 0755 -vp tools/gen-ssh.sh                               %{buildroot}%
 install -m 0755 -vp tools/image-info                               %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/run-koji-container.sh                    %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/koji-compose.py                          %{buildroot}%{_libexecdir}/osbuild-composer-test/
+install -m 0755 -vp tools/koji-compose-v2.py                       %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/libvirt_test.sh                          %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/set-env-variables.sh                     %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/test-case-generators/generate-test-cases %{buildroot}%{_libexecdir}/osbuild-composer-test/
diff --git a/tools/koji-compose-v2.py b/tools/koji-compose-v2.py
new file mode 100755
index 000000000..bbde97e5a
--- /dev/null
+++ b/tools/koji-compose-v2.py
@@ -0,0 +1,110 @@
+#!/usr/bin/python3
+import json
+import sys
+import time
+
+import requests
+
+
+# Composer API for Koji uses a slightly different repository format
+# that osbuild-composer does in /usr/share/osbuild-composer/repositories.
+#
+# This function does the conversion.
+def composer_repository_to_koji_repository(repository):
+    koji_repository = {
+        "baseurl": repository["baseurl"]
+    }
+
+    if repository.get("check_gpg", False):
+        koji_repository["gpgkey"] = repository["gpgkey"]
+
+    return koji_repository
+
+
+def compose_request(distro, koji, arch):
+    with open(f"/usr/share/tests/osbuild-composer/repositories/{distro}.json") as f:
+        test_repositories = json.load(f)
+
+    repositories = [composer_repository_to_koji_repository(repo) for repo in test_repositories[arch]]
+    image_requests = [
+        {
+            "architecture": "x86_64",
+            "image_type": "guest-image",
+            "repositories": repositories
+        },
+        {
+            "architecture": "x86_64",
+            "image_type": "aws",
+            "repositories": repositories
+        }
+    ]
+
+    req = {
+        "distribution": distro,
+        "koji": {
+            "server": koji,
+            "task_id": 1,
+            "name": "name",
+            "version": "version",
+            "release": "release",
+        },
+        "image_requests": image_requests
+    }
+
+    return req
+
+
+def main(distro, arch):
+    cr = compose_request(distro, "https://localhost:4343/kojihub", arch)
+    print(json.dumps(cr))
+
+    r = requests.post("https://localhost/api/image-builder-composer/v2/compose", json=cr,
+                      cert=("/etc/osbuild-composer/worker-crt.pem", "/etc/osbuild-composer/worker-key.pem"),
+                      verify="/etc/osbuild-composer/ca-crt.pem")
+    if r.status_code != 201:
+        print("Failed to create compose")
+        print(r.text)
+        sys.exit(1)
+
+    print(r.text)
+    compose_id = r.json()["id"]
+
+    while True:
+        r = requests.get(f"https://localhost/api/image-builder-composer/v2/composes/{compose_id}",
+                         cert=("/etc/osbuild-composer/worker-crt.pem", "/etc/osbuild-composer/worker-key.pem"),
+                         verify="/etc/osbuild-composer/ca-crt.pem")
+        if r.status_code != 200:
+            print("Failed to get compose status")
+            print(r.text)
+            sys.exit(1)
+        status = r.json()["status"]
+        print(status)
+        if status == "success":
+            print("Compose worked!")
+            print(r.text)
+            break
+        elif status == "failure":
+            print("compose failed!")
+            print(r.text)
+            sys.exit(1)
+        elif status != "pending" and status != "running":
+            print(f"unexpected status: {status}")
+            print(r.text)
+            sys.exit(1)
+
+        time.sleep(10)
+
+    r = requests.get(f"https://localhost/api/image-builder-composer/v2/composes/{compose_id}/logs",
+                     cert=("/etc/osbuild-composer/worker-crt.pem", "/etc/osbuild-composer/worker-key.pem"),
+                     verify="/etc/osbuild-composer/ca-crt.pem")
+    logs = r.json()
+    assert "image_builds" in logs
+    assert type(logs["image_builds"]) == list
+    assert len(logs["image_builds"]) == len(cr["image_requests"])
+
+
+if __name__ == "__main__":
+    if len(sys.argv) != 3:
+        print(f"usage: {sys.argv[0]} DISTRO ARCH", file=sys.stderr)
+        sys.exit(1)
+    main(sys.argv[1], sys.argv[2])


Test patch:
diff --git a/internal/cloudapi/v2/v2_internal_test.go b/internal/cloudapi/v2/v2_internal_test.go
new file mode 100644
index 000000000..07695118a
--- /dev/null
+++ b/internal/cloudapi/v2/v2_internal_test.go
@@ -0,0 +1,25 @@
+package v2
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestSplitExtension(t *testing.T) {
+	tests := []struct {
+		filename  string
+		extension string
+	}{
+		{filename: "image.qcow2", extension: ".qcow2"},
+		{filename: "image.tar.gz", extension: ".tar.gz"},
+		{filename: "", extension: ""},
+		{filename: ".htaccess", extension: ""},
+		{filename: ".weirdfile.txt", extension: ".txt"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.filename, func(t *testing.T) {
+			require.Equal(t, tt.extension, splitExtension(tt.filename))
+		})
+	}
+}
diff --git a/internal/cloudapi/v2/v2_koji_test.go b/internal/cloudapi/v2/v2_koji_test.go
new file mode 100644
index 000000000..24d4d46ef
--- /dev/null
+++ b/internal/cloudapi/v2/v2_koji_test.go
@@ -0,0 +1,564 @@
+package v2_test
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"testing"
+	"time"
+
+	"github.com/google/uuid"
+	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"
+	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild2"
+	"github.com/osbuild/osbuild-composer/internal/test"
+	"github.com/osbuild/osbuild-composer/internal/worker"
+	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
+	"github.com/stretchr/testify/require"
+)
+
+type jobResult struct {
+	Result interface{} `json:"result"`
+}
+
+func TestKojiCompose(t *testing.T) {
+	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
+	require.NoError(t, err)
+	defer os.RemoveAll(dir)
+	kojiServer, workerServer, cancel := newV2Server(t, dir)
+	handler := kojiServer.Handler("/api/image-builder-composer/v2")
+	workerHandler := workerServer.Handler()
+	defer cancel()
+
+	type kojiCase struct {
+		initResult       worker.KojiInitJobResult
+		buildResult      worker.OSBuildKojiJobResult
+		finalizeResult   worker.KojiFinalizeJobResult
+		composeReplyCode int
+		composeReply     string
+		composeStatus    string
+	}
+
+	var cases = []kojiCase{
+		{
+			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,
+				},
+			},
+			composeReplyCode: http.StatusCreated,
+			composeReply:     `{"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"}`,
+			composeStatus: `{
+				"kind": "ComposeStatus",
+				"image_status": {
+					"status": "success"
+				},
+				"image_statuses": [
+					{
+						"status": "success"
+					},
+					{
+						"status": "success"
+					}
+				],
+				"koji_status": {
+					"build_id": 42
+				},
+				"status": "success"
+			}`,
+		},
+		{
+			initResult: worker.KojiInitJobResult{
+				KojiError: "failure",
+			},
+			buildResult: worker.OSBuildKojiJobResult{
+				Arch:      test_distro.TestArchName,
+				HostOS:    test_distro.TestDistroName,
+				ImageHash: "browns",
+				ImageSize: 42,
+				OSBuildOutput: &osbuild.Result{
+					Success: true,
+				},
+			},
+			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": "failure"
+					}
+				],
+				"koji_status": {},
+				"status": "failure"
+			}`,
+		},
+		{
+			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,
+				OSBuildOutput: &osbuild.Result{
+					Success: true,
+				},
+			},
+			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": "failure"
+					}
+				],
+				"koji_status": {},
+				"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: 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"`,
+			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,
+				},
+				JobResult: worker.JobResult{
+					JobError: clienterrors.WorkerClientError(clienterrors.ErrorBuildJob, "Koji build error"),
+				},
+			},
+			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,
+				},
+			},
+			finalizeResult: worker.KojiFinalizeJobResult{
+				KojiError: "failure",
+			},
+			composeReplyCode: http.StatusCreated,
+			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeStatus: `{
+				"kind": "ComposeStatus",
+				"image_status": {
+					"status": "success"
+				},
+				"image_statuses": [
+					{
+						"status": "success"
+					},
+					{
+						"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,
+				},
+			},
+			finalizeResult: worker.KojiFinalizeJobResult{
+				JobResult: worker.JobResult{
+					JobError: clienterrors.WorkerClientError(clienterrors.ErrorKojiFinalize, "Koji finalize error"),
+				},
+			},
+			composeReplyCode: http.StatusCreated,
+			composeReply:     `"href":"/api/image-builder-composer/v2/compose", "kind":"ComposeId"`,
+			composeStatus: `{
+				"kind": "ComposeStatus",
+				"image_status": {
+					"status": "success"
+				},
+				"image_statuses": [
+					{
+						"status": "success"
+					},
+					{
+						"status": "success"
+					}
+				],
+				"koji_status": {
+					"build_id": 42
+				},
+				"status": "failure"
+			}`,
+		},
+	}
+	for _, c := range cases[2:3] {
+		test.TestRoute(t, handler, false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+		{
+			"distribution":"%[1]s",
+			"image_requests": [
+				{
+					"architecture": "%[2]s",
+					"image_type": "%[3]s",
+					"repositories": [
+						{
+							"baseurl": "https://repo.example.com/"
+						}
+					]
+				},
+				{
+					"architecture": "%[2]s",
+					"image_type": "%[3]s",
+					"repositories": [
+						{
+							"baseurl": "https://repo.example.com/"
+						}
+					]
+				}
+			],
+			"koji": {
+				"server": "koji.example.com",
+				"name":"foo",
+				"version":"1",
+				"release":"2"
+			}
+		}`, 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"})
+		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"})
+		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"})
+		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
+					}
+				}
+			}`, 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"})
+		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`)
+
+		// 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`)
+	}
+}
+
+func TestKojiRequest(t *testing.T) {
+	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
+	require.NoError(t, err)
+	defer os.RemoveAll(dir)
+	server, _, cancel := newV2Server(t, dir)
+	handler := server.Handler("/api/image-builder-composer/v2")
+	defer cancel()
+
+	// Make request to an invalid route
+	req := httptest.NewRequest("GET", "/invalidroute", nil)
+
+	rec := httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+	resp := rec.Result()
+
+	var status api.Status
+	err = json.NewDecoder(resp.Body).Decode(&status)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusNotFound, resp.StatusCode)
+
+	// Trigger an error 400 code
+	req = httptest.NewRequest("GET", "/api/image-builder-composer/v2/composes/badid", nil)
+
+	rec = httptest.NewRecorder()
+	handler.ServeHTTP(rec, req)
+	resp = rec.Result()
+
+	err = json.NewDecoder(resp.Body).Decode(&status)
+	require.NoError(t, err)
+	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
+}
+
+func TestKojiJobTypeValidation(t *testing.T) {
+	dir, err := ioutil.TempDir("", "osbuild-composer-test-api-v2-")
+	require.NoError(t, err)
+	defer os.RemoveAll(dir)
+	server, workers, cancel := newV2Server(t, dir)
+	handler := server.Handler("/api/image-builder-composer/v2")
+	defer cancel()
+
+	// Enqueue a compose job with N images (+ an Init and a Finalize job)
+	// Enqueuing them manually gives us access to the job IDs to use in
+	// requests.
+	// TODO: set to 4
+	nImages := 1
+	initJob := worker.KojiInitJob{
+		Server:  "test-server",
+		Name:    "test-job",
+		Version: "42",
+		Release: "1",
+	}
+	initID, err := workers.EnqueueKojiInit(&initJob)
+	require.NoError(t, err)
+
+	buildJobs := make([]worker.OSBuildKojiJob, 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,
+		}
+		buildID, err := workers.EnqueueOSBuildKoji(fmt.Sprintf("fake-arch-%d", idx), &buildJob, initID)
+		require.NoError(t, err)
+
+		buildJobs[idx] = buildJob
+		buildJobIDs[idx] = buildID
+		filenames[idx] = fname
+	}
+
+	finalizeJob := worker.KojiFinalizeJob{
+		Server:        "test-server",
+		Name:          "test-job",
+		Version:       "42",
+		Release:       "1",
+		KojiFilenames: filenames,
+		KojiDirectory: "koji-server-test-dir",
+		TaskID:        0,
+		StartTime:     uint64(time.Now().Unix()),
+	}
+	finalizeID, err := workers.EnqueueKojiFinalize(&finalizeJob, initID, buildJobIDs)
+	require.NoError(t, err)
+
+	// ----- Jobs queued - Test API endpoints (status, manifests, logs) ----- //
+
+	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, "*")
+
+		// The other IDs should fail
+		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`)
+		}
+
+		badID := uuid.New()
+		test.TestRoute(t, handler, false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%s%s", badID, path), ``, http.StatusNotFound, `{"code":"IMAGE-BUILDER-COMPOSER-15","href":"/api/image-builder-composer/v2/errors/15","id":"15","kind":"Error","reason":"Compose with given id not found"}`, `operation_id`)
+	}
+}
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index eb2f731c5..7ed367a5b 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -248,6 +248,29 @@ func TestCompose(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/compose",
 		"kind": "ComposeId"
 	}`, "id")
+	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
+	{
+		"distribution": "%s",
+		"koji":{
+			"name": "name",
+			"version": "version",
+			"release": "release",
+			"server": "https://koji.example.com",
+			"task_id": 42
+		},
+		"image_request":{
+			"architecture": "%s",
+			"image_type": "aws",
+			"repositories": [{
+				"baseurl": "somerepo.org",
+				"rhsm": false
+			}]
+		 }
+	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
+	{
+		"href": "/api/image-builder-composer/v2/compose",
+		"kind": "ComposeId"
+	}`, "id")
 }
 
 func TestComposeStatusSuccess(t *testing.T) {
@@ -292,7 +315,8 @@ func TestComposeStatusSuccess(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "building"}
+		"image_status": {"status": "building"},
+		"status": "pending"
 	}`, jobId, jobId))
 
 	res, err := json.Marshal(&worker.OSBuildJobResult{
@@ -307,7 +331,8 @@ func TestComposeStatusSuccess(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "success"}
+		"image_status": {"status": "success"},
+		"status": "success"
 	}`, jobId, jobId))
 
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v/metadata", jobId), ``, http.StatusInternalServerError, `
@@ -356,7 +381,8 @@ func TestComposeStatusFailure(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "building"}
+		"image_status": {"status": "building"},
+		"status": "pending"
 	}`, jobId, jobId))
 
 	err = wrksrv.FinishJob(token, nil)
@@ -366,7 +392,8 @@ func TestComposeStatusFailure(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "failure"}
+		"image_status": {"status": "failure"},
+		"status": "failure"
 	}`, jobId, jobId))
 }
 
@@ -406,7 +433,8 @@ func TestComposeLegacyError(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "building"}
+		"image_status": {"status": "building"},
+		"status": "pending"
 	}`, jobId, jobId))
 
 	jobResult, err := json.Marshal(worker.OSBuildJobResult{TargetErrors: []string{"Osbuild failed"}})
@@ -419,7 +447,8 @@ func TestComposeLegacyError(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "failure"}
+		"image_status": {"status": "failure"},
+		"status": "failure"
 	}`, jobId, jobId))
 }
 
@@ -459,7 +488,8 @@ func TestComposeJobError(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "building"}
+		"image_status": {"status": "building"},
+		"status": "pending"
 	}`, jobId, jobId))
 
 	jobErr := worker.JobResult{
@@ -475,7 +505,8 @@ func TestComposeJobError(t *testing.T) {
 		"href": "/api/image-builder-composer/v2/composes/%v",
 		"kind": "ComposeStatus",
 		"id": "%v",
-		"image_status": {"status": "failure"}
+		"image_status": {"status": "failure"},
+		"status": "failure"
 	}`, jobId, jobId))
 }
 
diff --git a/internal/jobqueue/jobqueuetest/jobqueuetest.go b/internal/jobqueue/jobqueuetest/jobqueuetest.go
index 9d6b629fe..b7f403a3e 100644
--- a/internal/jobqueue/jobqueuetest/jobqueuetest.go
+++ b/internal/jobqueue/jobqueuetest/jobqueuetest.go
@@ -188,8 +188,9 @@ func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 		require.ElementsMatch(t, []uuid.UUID{one, two}, r)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		_, queued, started, finished, canceled, deps, err := q.JobStatus(j)
+		jobType, _, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
+		require.Equal(t, jobType, "test")
 		require.True(t, !queued.IsZero())
 		require.True(t, started.IsZero())
 		require.True(t, finished.IsZero())
@@ -198,8 +199,9 @@ func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}, []uuid.UUID{one, two}))
 
-		result, queued, started, finished, canceled, deps, err := q.JobStatus(j)
+		jobType, result, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
+		require.Equal(t, jobType, "test")
 		require.True(t, !queued.IsZero())
 		require.True(t, !started.IsZero())
 		require.True(t, !finished.IsZero())
@@ -215,8 +217,9 @@ func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 		two := pushTestJob(t, q, "test", nil, nil)
 
 		j := pushTestJob(t, q, "test", nil, []uuid.UUID{one, two})
-		_, queued, started, finished, canceled, deps, err := q.JobStatus(j)
+		jobType, _, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
+		require.Equal(t, jobType, "test")
 		require.True(t, !queued.IsZero())
 		require.True(t, started.IsZero())
 		require.True(t, finished.IsZero())
@@ -230,8 +233,9 @@ func testDependencies(t *testing.T, q jobqueue.JobQueue) {
 
 		require.Equal(t, j, finishNextTestJob(t, q, "test", testResult{}, []uuid.UUID{one, two}))
 
-		result, queued, started, finished, canceled, deps, err := q.JobStatus(j)
+		jobType, result, queued, started, finished, canceled, deps, err := q.JobStatus(j)
 		require.NoError(t, err)
+		require.Equal(t, jobType, "test")
 		require.True(t, !queued.IsZero())
 		require.True(t, !started.IsZero())
 		require.True(t, !finished.IsZero())
@@ -325,8 +329,9 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	require.NotEmpty(t, id)
 	err = q.CancelJob(id)
 	require.NoError(t, err)
-	result, _, _, _, canceled, _, err := q.JobStatus(id)
+	jobType, result, _, _, _, canceled, _, err := q.JobStatus(id)
 	require.NoError(t, err)
+	require.Equal(t, jobType, "clownfish")
 	require.True(t, canceled)
 	require.Nil(t, result)
 	err = q.FinishJob(id, &testResult{})
@@ -344,8 +349,9 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	require.Equal(t, json.RawMessage("null"), args)
 	err = q.CancelJob(id)
 	require.NoError(t, err)
-	result, _, _, _, canceled, _, err = q.JobStatus(id)
+	jobType, result, _, _, _, canceled, _, err = q.JobStatus(id)
 	require.NoError(t, err)
+	require.Equal(t, jobType, "clownfish")
 	require.True(t, canceled)
 	require.Nil(t, result)
 	err = q.FinishJob(id, &testResult{})
@@ -366,8 +372,9 @@ func testCancel(t *testing.T, q jobqueue.JobQueue) {
 	err = q.CancelJob(id)
 	require.Error(t, err)
 	require.Equal(t, jobqueue.ErrNotRunning, err)
-	result, _, _, _, canceled, _, err = q.JobStatus(id)
+	jobType, result, _, _, _, canceled, _, err = q.JobStatus(id)
 	require.NoError(t, err)
+	require.Equal(t, jobType, "clownfish")
 	require.False(t, canceled)
 	err = json.Unmarshal(result, &testResult{})
 	require.NoError(t, err)
diff --git a/internal/weldr/compose_test.go b/internal/weldr/compose_test.go
index 5f1d8d1d2..926fa01d3 100644
--- a/internal/weldr/compose_test.go
+++ b/internal/weldr/compose_test.go
@@ -54,7 +54,7 @@ func TestComposeStatusFromLegacyError(t *testing.T) {
 	err = api.workers.FinishJob(token, rawResult)
 	require.NoError(t, err)
 
-	jobStatus, _, err := api.workers.JobStatus(jobId, &jobResult)
+	jobStatus, _, err := api.workers.OSBuildJobStatus(jobId, &jobResult)
 	require.NoError(t, err)
 
 	state := composeStateFromJobStatus(jobStatus, &jobResult)
@@ -101,7 +101,7 @@ func TestComposeStatusFromJobError(t *testing.T) {
 	err = api.workers.FinishJob(token, rawResult)
 	require.NoError(t, err)
 
-	jobStatus, _, err := api.workers.JobStatus(jobId, &jobResult)
+	jobStatus, _, err := api.workers.OSBuildJobStatus(jobId, &jobResult)
 	require.NoError(t, err)
 
 	state := composeStateFromJobStatus(jobStatus, &jobResult)
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index 502c759c2..e3f4a1323 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -246,12 +246,9 @@ func TestArgs(t *testing.T) {
 	require.NotNil(t, args)
 
 	var jobArgs worker.OSBuildJob
-	jobType, rawArgs, deps, err := server.Job(jobId, &jobArgs)
+	err = server.OSBuildJob(jobId, &jobArgs)
 	require.NoError(t, err)
-	require.Equal(t, args, rawArgs)
 	require.Equal(t, job, jobArgs)
-	require.Equal(t, jobType, "osbuild:"+arch.Name())
-	require.Equal(t, []uuid.UUID(nil), deps)
 }
 
 func TestUpload(t *testing.T) {
@@ -489,8 +486,8 @@ func TestMixedOSBuildJob(t *testing.T) {
 	newJobID, err := server.EnqueueOSBuild("x", &newJob)
 	require.NoError(err)
 
-	oldJobRead := new(worker.OSBuildJob)
-	_, _, _, err = server.Job(oldJobID, oldJobRead)
+	var oldJobRead worker.OSBuildJob
+	err = server.OSBuildJob(oldJobID, &oldJobRead)
 	require.NoError(err)
 	require.NotNil(oldJobRead.PipelineNames)
 	// OldJob gets default pipeline names when read
@@ -501,8 +498,8 @@ func TestMixedOSBuildJob(t *testing.T) {
 	require.NotEqual(oldJob, oldJobRead)
 
 	// NewJob the same when read back
-	newJobRead := new(worker.OSBuildJob)
-	_, _, _, err = server.Job(newJobID, newJobRead)
+	var newJobRead worker.OSBuildJob
+	err = server.OSBuildJob(newJobID, &newJobRead)
 	require.NoError(err)
 	require.NotNil(newJobRead.PipelineNames)
 	require.Equal(newJob.PipelineNames, newJobRead.PipelineNames)
@@ -557,7 +554,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 	require.NoError(err)
 
 	oldJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.JobStatus(oldJobID, oldJobResultRead)
+	_, _, err = server.OSBuildJobStatus(oldJobID, oldJobResultRead)
 	require.NoError(err)
 
 	// oldJobResultRead should have PipelineNames now
@@ -595,7 +592,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 	require.NoError(err)
 
 	newJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.JobStatus(newJobID, newJobResultRead)
+	_, _, err = server.OSBuildJobStatus(newJobID, newJobResultRead)
 	require.NoError(err)
 	require.Equal(newJobResult, newJobResultRead)
 }
@@ -637,8 +634,8 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	}
 	newJobID := enqueueKojiJob(&newJob)
 
-	oldJobRead := new(worker.OSBuildKojiJob)
-	_, _, _, err = server.Job(oldJobID, oldJobRead)
+	var oldJobRead worker.OSBuildKojiJob
+	err = server.OSBuildKojiJob(oldJobID, &oldJobRead)
 	require.NoError(err)
 	require.NotNil(oldJobRead.PipelineNames)
 	// OldJob gets default pipeline names when read
@@ -649,8 +646,8 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	require.NotEqual(oldJob, oldJobRead)
 
 	// NewJob the same when read back
-	newJobRead := new(worker.OSBuildKojiJob)
-	_, _, _, err = server.Job(newJobID, newJobRead)
+	var newJobRead worker.OSBuildKojiJob
+	err = server.OSBuildKojiJob(newJobID, &newJobRead)
 	require.NoError(err)
 	require.NotNil(newJobRead.PipelineNames)
 	require.Equal(newJob.PipelineNames, newJobRead.PipelineNames)
@@ -715,7 +712,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	require.NoError(err)
 
 	oldJobResultRead := new(worker.OSBuildKojiJobResult)
-	_, _, err = server.JobStatus(oldJobID, oldJobResultRead)
+	_, _, err = server.OSBuildKojiJobStatus(oldJobID, oldJobResultRead)
 	require.NoError(err)
 
 	// oldJobResultRead should have PipelineNames now
@@ -755,7 +752,7 @@ func TestMixedOSBuildKojiJob(t *testing.T) {
 	require.NoError(err)
 
 	newJobResultRead := new(worker.OSBuildKojiJobResult)
-	_, _, err = server.JobStatus(newJobID, newJobResultRead)
+	_, _, err = server.OSBuildKojiJobStatus(newJobID, newJobResultRead)
 	require.NoError(err)
 	require.Equal(newJobResult, newJobResultRead)
 }
@@ -794,7 +791,7 @@ func TestDepsolveLegacyErrorConversion(t *testing.T) {
 		ErrorType: errType,
 	}
 
-	_, _, err = server.JobStatus(depsolveJobId, &depsolveJobResult)
+	_, _, err = server.DepsolveJobStatus(depsolveJobId, &depsolveJobResult)
 	require.NoError(t, err)
 	require.Equal(t, expectedResult, depsolveJobResult)
 }
@@ -830,14 +827,14 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 	require.NoError(err)
 
 	oldJobRead := new(worker.OSBuildJob)
-	_, _, _, err = server.Job(oldJobID, oldJobRead)
+	err = server.OSBuildJob(oldJobID, oldJobRead)
 	require.NoError(err)
 	// Not entirely equal
 	require.NotEqual(oldJob, oldJobRead)
 
 	// NewJob the same when read back
 	newJobRead := new(worker.OSBuildJob)
-	_, _, _, err = server.Job(newJobID, newJobRead)
+	err = server.OSBuildJob(newJobID, newJobRead)
 	require.NoError(err)
 
 	// Dequeue the jobs (via RequestJob) to get their tokens and update them to
@@ -876,7 +873,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 	require.NoError(err)
 
 	oldJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.JobStatus(oldJobID, oldJobResultRead)
+	_, _, err = server.OSBuildJobStatus(oldJobID, oldJobResultRead)
 	require.NoError(err)
 
 	require.NotEqual(oldJobResult, oldJobResultRead)
@@ -899,7 +896,7 @@ func TestMixedOSBuildJobErrors(t *testing.T) {
 	require.NoError(err)
 
 	newJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.JobStatus(newJobID, newJobResultRead)
+	_, _, err = server.OSBuildJobStatus(newJobID, newJobResultRead)
 	require.NoError(err)
 	require.Equal(newJobResult, newJobResultRead)
 	require.Equal(newJobResult.Success, false)
@@ -943,14 +940,14 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 	newJobID := enqueueKojiJob(&newJob)
 
 	oldJobRead := new(worker.OSBuildKojiJob)
-	_, _, _, err = server.Job(oldJobID, oldJobRead)
+	err = server.OSBuildKojiJob(oldJobID, oldJobRead)
 	require.NoError(err)
 	// Not entirely equal
 	require.NotEqual(oldJob, oldJobRead)
 
 	// NewJob the same when read back
 	newJobRead := new(worker.OSBuildKojiJob)
-	_, _, _, err = server.Job(newJobID, newJobRead)
+	err = server.OSBuildKojiJob(newJobID, newJobRead)
 	require.NoError(err)
 
 	// Dequeue the jobs (via RequestJob) to get their tokens and update them to
@@ -998,7 +995,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 	require.NoError(err)
 
 	oldJobResultRead := new(worker.OSBuildKojiJobResult)
-	_, _, err = server.JobStatus(oldJobID, oldJobResultRead)
+	_, _, err = server.OSBuildKojiJobStatus(oldJobID, oldJobResultRead)
 	require.NoError(err)
 
 	// oldJobResultRead should have PipelineNames now
@@ -1020,7 +1017,7 @@ func TestMixedOSBuildKojiJobErrors(t *testing.T) {
 	require.NoError(err)
 
 	newJobResultRead := new(worker.OSBuildKojiJobResult)
-	_, _, err = server.JobStatus(newJobID, newJobResultRead)
+	_, _, err = server.OSBuildKojiJobStatus(newJobID, newJobResultRead)
 	require.NoError(err)
 	require.Equal(newJobResult, newJobResultRead)
 }
diff --git a/test/cases/koji.sh b/test/cases/koji.sh
index e5b8a4fd6..31386d883 100755
--- a/test/cases/koji.sh
+++ b/test/cases/koji.sh
@@ -57,8 +57,13 @@ if [[ "$DISTRO_CODE" == rhel-8* ]]; then
   DISTRO_CODE=rhel-86
 fi
 
-greenprint "Pushing compose to Koji"
-sudo /usr/libexec/osbuild-composer-test/koji-compose.py "$DISTRO_CODE" "${ARCH}"
+if [ "${COMPOSER_API:=true}" == "true" ]; then
+    greenprint "Pushing compose to Koji (/api/image-builder-comoser/v2/"
+    sudo /usr/libexec/osbuild-composer-test/koji-compose-v2.py "$DISTRO_CODE" "${ARCH}"
+else
+    greenprint "Pushing compose to Koji (/api/comoser-koji/v1/"
+    sudo /usr/libexec/osbuild-composer-test/koji-compose.py "$DISTRO_CODE" "${ARCH}"
+fi
 
 greenprint "Show Koji task"
 koji --server=http://localhost:8080/kojihub taskinfo 1
diff --git a/test/data/manifests/rhel_90-x86_64-image_installer-boot.json b/test/data/manifests/rhel_90-x86_64-image_installer-boot.json
index 335d287ba..9596abdcc 100644
--- a/test/data/manifests/rhel_90-x86_64-image_installer-boot.json
+++ b/test/data/manifests/rhel_90-x86_64-image_installer-boot.json
@@ -95,7 +95,6 @@
                   "sha256:82a35422382dabf9607d0888f966dd7c75a89dd6147a488892e73e00a28b5ed4",
                   "sha256:6cbc1e6b5952017f85eb08eef8a07a4adfdbe1e57f6b684b6d812cc6177893a1",
                   "sha256:51c3f858023baa419b1976f8e98a70e07e71b7675ba49d7e4ab555b7bec4fde7",
-                  "sha256:264dfc9e9a577b3bfb58eb14dd2e2e3e044e6b891be0f35237f100ab83b9a83f",
                   "sha256:4e49a61f7e60aeb6f4f632e05ad0e9f9132258555df2d338377d442d8c7d1a43",
                   "sha256:3ba40b602c1a41741ee7de075e5507d8eaabd5d328c0c18aeafb42b1644db08d",
                   "sha256:f2d033d12fba32931a5db0b27cc0b591b8620738cae6c73ef058aab52b4e8c1d",
@@ -245,7 +244,6 @@
                   "sha256:837f4f1591fdb2c8c0eb8b5fb4976396e8aa85036c2818490d278d8e9ba3bf35",
                   "sha256:ecb5f365c2c27d37159b65cd60f22a8c1383a0608ff23206ba813bd0709cb0c7",
                   "sha256:dfef22b869148c0d88673a4139874d34f6dfa9af017467c3c7b0cf60a62f1943",
-                  "sha256:c96c8e5d6b359d88c7b6b2990fac5e9c43a5b09888d96779099a00077aaeebe7",
                   "sha256:dd6c970a0b0f9dd3509036090669a64af1449cde2f02810a5f666db9936fdd4c",
                   "sha256:caf797ef8f54ea443d435b970b79f9a09588135b54b7c10bd7dc1a335e16ef2e",
                   "sha256:7190b1f00b96d9ea7c82f09176376ed41eb050c65ab00656f047560427a8219d",
@@ -1099,7 +1097,6 @@
                   "sha256:6cbc1e6b5952017f85eb08eef8a07a4adfdbe1e57f6b684b6d812cc6177893a1",
                   "sha256:4f21b0f1a6f81e9674390201f10b144902e5373b1909a721c4292858784273d9",
                   "sha256:51c3f858023baa419b1976f8e98a70e07e71b7675ba49d7e4ab555b7bec4fde7",
-                  "sha256:264dfc9e9a577b3bfb58eb14dd2e2e3e044e6b891be0f35237f100ab83b9a83f",
                   "sha256:4e49a61f7e60aeb6f4f632e05ad0e9f9132258555df2d338377d442d8c7d1a43",
                   "sha256:3ba40b602c1a41741ee7de075e5507d8eaabd5d328c0c18aeafb42b1644db08d",
                   "sha256:f2d033d12fba32931a5db0b27cc0b591b8620738cae6c73ef058aab52b4e8c1d",
@@ -1359,7 +1356,6 @@
                   "sha256:0ca6ceab03e87d70d0303849511cd606d6fe41fb5c3cccd5645e876e4b73023f",
                   "sha256:ecb5f365c2c27d37159b65cd60f22a8c1383a0608ff23206ba813bd0709cb0c7",
                   "sha256:dfef22b869148c0d88673a4139874d34f6dfa9af017467c3c7b0cf60a62f1943",
-                  "sha256:c96c8e5d6b359d88c7b6b2990fac5e9c43a5b09888d96779099a00077aaeebe7",
                   "sha256:dd6c970a0b0f9dd3509036090669a64af1449cde2f02810a5f666db9936fdd4c",
                   "sha256:d3237a7764708789d98b3332a6978037efe1f4f8f256fed75ee5c2877ad9160a",
                   "sha256:98a679dd55b94522c245d397f54ed7a0d7c402cecb95971ff6f261e42c7741d0",
@@ -1994,7 +1990,6 @@
               "isolabel": "RHEL-9-0-0-BaseOS-x86_64",
               "efi": {
                 "architectures": [
-                  "IA32",
                   "X64"
                 ],
                 "vendor": "redhat"
@@ -2507,9 +2502,6 @@
           "sha256:2642e4623fcf504cfaaeac1bfec8d5dab0de93b2f038931e7789461cf3107c59": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210930/Packages/rpm-plugin-systemd-inhibit-4.16.1.3-7.el9.x86_64.rpm"
           },
-          "sha256:264dfc9e9a577b3bfb58eb14dd2e2e3e044e6b891be0f35237f100ab83b9a83f": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/grub2-efi-ia32-cdboot-2.06~rc1-8.el9.x86_64.rpm"
-          },
           "sha256:266167efe47d97803fad32ec52453f63a0d6d1fc7b7d3c6b90bb3e1a43aaa95c": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210930/Packages/upower-0.99.13-2.el9.x86_64.rpm"
           },
@@ -4307,9 +4299,6 @@
           "sha256:c8a9ed01fb0adb98368b8917f15e0e35bfc6964a758a28e8f7a0839c57a43dcd": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-appstream-n9.0-20210930/Packages/anaconda-gui-34.25.0.16-1.el9.x86_64.rpm"
           },
-          "sha256:c96c8e5d6b359d88c7b6b2990fac5e9c43a5b09888d96779099a00077aaeebe7": {
-            "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/shim-ia32-15-16.el8.x86_64.rpm"
-          },
           "sha256:c99b07bbf7c6203d94c6a0b50fdd2812cc99ae4952f999d91b3856edd575be02": {
             "url": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/libgcc-11.2.1-2.2.el9.x86_64.rpm"
           },
@@ -6397,15 +6386,6 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/grub2-common-2.06~rc1-8.el9.noarch.rpm",
         "checksum": "sha256:51c3f858023baa419b1976f8e98a70e07e71b7675ba49d7e4ab555b7bec4fde7"
       },
-      {
-        "name": "grub2-efi-ia32-cdboot",
-        "epoch": 1,
-        "version": "2.06~rc1",
-        "release": "8.el9",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/grub2-efi-ia32-cdboot-2.06~rc1-8.el9.x86_64.rpm",
-        "checksum": "sha256:264dfc9e9a577b3bfb58eb14dd2e2e3e044e6b891be0f35237f100ab83b9a83f"
-      },
       {
         "name": "grub2-efi-x64",
         "epoch": 1,
@@ -7747,15 +7727,6 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/shared-mime-info-2.1-4.el9.x86_64.rpm",
         "checksum": "sha256:dfef22b869148c0d88673a4139874d34f6dfa9af017467c3c7b0cf60a62f1943"
       },
-      {
-        "name": "shim-ia32",
-        "epoch": 0,
-        "version": "15",
-        "release": "16.el8",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/shim-ia32-15-16.el8.x86_64.rpm",
-        "checksum": "sha256:c96c8e5d6b359d88c7b6b2990fac5e9c43a5b09888d96779099a00077aaeebe7"
-      },
       {
         "name": "shim-x64",
         "epoch": 0,
@@ -9315,15 +9286,6 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/grub2-common-2.06~rc1-8.el9.noarch.rpm",
         "checksum": "sha256:51c3f858023baa419b1976f8e98a70e07e71b7675ba49d7e4ab555b7bec4fde7"
       },
-      {
-        "name": "grub2-efi-ia32-cdboot",
-        "epoch": 1,
-        "version": "2.06~rc1",
-        "release": "8.el9",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/grub2-efi-ia32-cdboot-2.06~rc1-8.el9.x86_64.rpm",
-        "checksum": "sha256:264dfc9e9a577b3bfb58eb14dd2e2e3e044e6b891be0f35237f100ab83b9a83f"
-      },
       {
         "name": "grub2-efi-x64",
         "epoch": 1,
@@ -11655,15 +11617,6 @@
         "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/shared-mime-info-2.1-4.el9.x86_64.rpm",
         "checksum": "sha256:dfef22b869148c0d88673a4139874d34f6dfa9af017467c3c7b0cf60a62f1943"
       },
-      {
-        "name": "shim-ia32",
-        "epoch": 0,
-        "version": "15",
-        "release": "16.el8",
-        "arch": "x86_64",
-        "remote_location": "https://rpmrepo.osbuild.org/v2/mirror/rhvpn/el9/el9-x86_64-baseos-n9.0-20210930/Packages/shim-ia32-15-16.el8.x86_64.rpm",
-        "checksum": "sha256:c96c8e5d6b359d88c7b6b2990fac5e9c43a5b09888d96779099a00077aaeebe7"
-      },
       {
         "name": "shim-x64",
         "epoch": 0,