Repository: osbuild/osbuild-composer
Pull request #2194 (2022-01-14 16:55:46)

Dnf json
The goal is to be able to deploy dnf-json as a standalone service.

- cachedir should be able to be set by configuration rather than passed as argument
- dnf-json (at least the container) should be able to accept http connections


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/containers/osbuild-composer/entrypoint.py b/containers/osbuild-composer/entrypoint.py
index 685378e90..521f596e7 100644
--- a/containers/osbuild-composer/entrypoint.py
+++ b/containers/osbuild-composer/entrypoint.py
@@ -108,6 +108,13 @@ class Cli(contextlib.AbstractContextManager):
             dest="dnf_json",
             help="Disable dnf-json",
         )
+        self._parser.add_argument(
+            "--dnf-json-port",
+            type=int,
+            default=0,
+            dest="dnf_json_port",
+            help="Specify the port dnf-json should listen on",
+        )
 
         self._parser.set_defaults(
             builtin_worker=False,
@@ -258,9 +265,22 @@ class Cli(contextlib.AbstractContextManager):
             "/usr/libexec/osbuild-composer/dnf-json",
         ]
 
+        if self.args.dnf_json_port:
+            sock = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
+            self._exitstack.enter_context(contextlib.closing(sock))
+            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+            sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0)
+            sock.bind(("::", self.args.dnf_json_port))
+            sock.listen()
+        else:
+            sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+            self._exitstack.enter_context(contextlib.closing(sock))
+            sock.bind("/run/osbuild-dnf-json/api.sock")
+            sock.listen()
+
         dnfenv = os.environ.copy()
-        dnfenv["LISTEN_FDS"] = "0"
-        dnfenv["LISTEN_FDNAMES"] = ""
+        dnfenv["LISTEN_FDS"] = "1"
+        dnfenv["LISTEN_FD"] = str(sock.fileno())
 
         return subprocess.Popen(
             cmd,
@@ -268,6 +288,7 @@ class Cli(contextlib.AbstractContextManager):
             stdin=subprocess.DEVNULL,
             stderr=subprocess.STDOUT,
             env=dnfenv,
+            pass_fds=[sock.fileno()]
         )
 
     def run(self):
@@ -286,14 +307,20 @@ class Cli(contextlib.AbstractContextManager):
             if self.args.dnf_json:
                 proc_dnf_json = self._spawn_dnf_json()
 
-            proc_composer = self._spawn_composer(sockets)
+            if any([self.args.weldr_api, self.args.composer_api, self.args.local_worker_api, self.args.remote_worker_api]):
+                proc_composer = self._spawn_composer(sockets)
+
+            if proc_composer:
+                res = proc_composer.wait()
 
-            res = proc_composer.wait()
             if proc_worker:
-                proc_worker.terminate()
+                if proc_composer:
+                    proc_worker.terminate()
                 proc_worker.wait()
+
             if proc_dnf_json:
-                proc_dnf_json.terminate()
+                if proc_composer:
+                    proc_dnf_json.terminate()
                 proc_dnf_json.wait()
 
             return res
diff --git a/dnf-json b/dnf-json
index ed209b4d5..45ea28743 100755
--- a/dnf-json
+++ b/dnf-json
@@ -275,6 +275,13 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
         self.wfile.write(json.dumps({"kind": kind, "reason":
             reason}).encode("utf-8"))
 
+    def response_failure(self, json_object):
+        self._send()
+        self.send_response(500)
+        self.send_header("Content-Type", "application/json")
+        self.end_headers()
+        self.wfile.write(json.dumps(json_object).encode("utf-8"))
+
     def response_success(self, json_object):
         self._send()
         self.send_response(200)
@@ -305,10 +312,19 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
                 arguments = call["arguments"]
                 repos = arguments.get("repos", {})
                 arch = arguments["arch"]
-                self.cache_dir = arguments["cachedir"]
-                cache_state = CacheState.load_cache_state_from_disk(self.cache_dir)
                 module_platform_id = arguments["module_platform_id"]
 
+                # If dnf-json is run as a service, we don't want users to be able to set the cache
+                self.cache_dir = os.environ.get("OVERWRITE_CACHE_DIR", "")
+                if self.cache_dir:
+                    self.cache_dir = os.path.join(self.cache_dir, arch)
+                else:
+                    self.cache_dir = arguments.get("cachedir", "")
+
+                if not self.cache_dir:
+                    self.response_failure({ "kind": "Error", "reason": "No cache dir set" })
+                cache_state = CacheState.load_cache_state_from_disk(self.cache_dir)
+
                 with tempfile.TemporaryDirectory() as persistdir:
                     try:
                         solver = Solver(
@@ -358,24 +374,21 @@ class DnfJsonRequestHandler(BaseHTTPRequestHandler):
 log.info("Starting the dnf-json server")
 
 LISTEN_FDS = int(os.environ.get("LISTEN_FDS", 0))
-SOCK_PATH = "/run/osbuild-dnf-json/"
-SOCK_NAME = "api.sock"
+# set from entrypoint if differs from 3
+LISTEN_FD = int(os.environ.get("LISTEN_FD", 3))
 
 # The dnf-json web server has to use forks to serve the requests. Because the
 # dnf library is leaking memory in its Cpp side.
 class SystemDActivationSocketServer(socketserver.ForkingMixIn, socketserver.UnixStreamServer):
     def server_bind(self):
         log.debug("service bind")
-        if LISTEN_FDS == 0:
-            log.debug("create new socket")
-            socketserver.UnixStreamServer.server_bind(self)
-        else:
-            log.debug("rebind socket")
-            log.debug("address_family: %d ", self.address_family)
-            log.debug("socket_type: %d ", self.socket_type)
-            self.socket = socket.fromfd(3, self.address_family, self.socket_type)
+        log.debug("rebind socket")
+        log.debug("address_family: %d ", self.address_family)
+        log.debug("socket_type: %d ", self.socket_type)
+        if LISTEN_FDS > 1:
+            log.warning("More than one LISTEN_FDS")
+        self.socket = socket.fromfd(LISTEN_FD, self.address_family, self.socket_type)
 
 # start the web server
-pathlib.Path(SOCK_PATH).mkdir(parents=True, exist_ok=True)
-server = SystemDActivationSocketServer(f"{SOCK_PATH}{SOCK_NAME}", DnfJsonRequestHandler)
+server = SystemDActivationSocketServer('', DnfJsonRequestHandler)
 server.serve_forever()
diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go
index d3ce3da8b..90c8ae537 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"},
@@ -218,18 +224,17 @@ func apiErrorFromEchoError(echoError *echo.HTTPError) ServiceErrorCode {
 
 // Convert an echo error into an AOC compliant one so we send a correct json error response
 func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
-	doResponse := func(code ServiceErrorCode, c echo.Context) {
+	doResponse := func(code ServiceErrorCode, c echo.Context, internal error) {
 		if !c.Response().Committed {
 			var err error
 			sec := find(code)
 			apiErr := APIError(code, sec, c)
 
 			if sec.httpStatus == http.StatusInternalServerError {
-				internalError, ok := echoError.(*echo.HTTPError)
 				errMsg := fmt.Sprintf("Internal server error. Code: %s, OperationId: %s", apiErr.Code, apiErr.OperationId)
 
-				if ok {
-					errMsg += fmt.Sprintf(", InternalError: %v", internalError)
+				if internal != nil {
+					errMsg += fmt.Sprintf(", InternalError: %v", internal)
 				}
 
 				c.Logger().Error(errMsg)
@@ -251,7 +256,7 @@ func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
 	he, ok := echoError.(*echo.HTTPError)
 	if !ok {
 		c.Logger().Errorf("ErrorNotHTTPError %v", echoError)
-		doResponse(ErrorNotHTTPError, c)
+		doResponse(ErrorNotHTTPError, c, echoError)
 		return
 	}
 
@@ -265,8 +270,8 @@ func (s *Server) HTTPErrorHandler(echoError error, c echo.Context) {
 	sec, ok := he.Message.(ServiceErrorCode)
 	if !ok {
 		// No service code was set, so Echo threw this error
-		doResponse(apiErrorFromEchoError(he), c)
+		doResponse(apiErrorFromEchoError(he), c, he.Internal)
 		return
 	}
-	doResponse(sec, c)
+	doResponse(sec, c, he.Internal)
 }
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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",
+	"H4sIAAAAAAAC/+xce2/ctrL/KoTOBdziSrvyPhx7gaLHcdwct0kc2E4P7o0NgyvNrhhLpEpSXm8Cf/cL",
+	"PqTVg/tw6/bcc+B/Gq9Ezgx/nBnOg+o3L2JZzihQKbzJNy/HHGcggdtfc1D/xiAiTnJJGPUm3kc8B0Ro",
+	"DA+e78EDzvIUGsPvcVqAN/H2vcdH3yNqzm8F8KXnexRn6o0e6XsiSiDDaopc5uq5kJzQuZ4myFcH7w9F",
+	"NgWO2AwRCZlAhCLAUYIswbo0JYFKmjBcK48eu0mex/KlJn38z8vTk8GnPGU4PteimfVzlgOXxPDnMNcy",
+	"fyul8iYeFMEChAz2Pb/NwvdEgjncLohMbnEUscJuSTX7s7c/GI7GB68Oj8L9gXfjexoDh7gVccw5Xmra",
+	"FOciYfLWLLguU7YMyrddqR59j8NvBeEQKwHsmtyy3lSz2fQLRFLxrSN1KbEsHEDhjDQlwhkJwuhwGL46",
+	"Gr56NR4fjePR1IXYEyFuLUbxrWisEf5y+Ly77MZzC/N1wBU8ddtOnYUa5KT/teCwZXEkw3OoVKZliTgD",
+	"ZYcyAVRoMhAjPaGHziTKCiHRFFBByW+Fchd64JzcA0UcBCt4BGjOWZH3runZDCkmiAjEMiIlxGjGWaan",
+	"qLWAkD7CiGMaswwxCmiKBcSIUYTRp09nbxAR13QOFDiWEPeu6coXGA3XgrlUKGURlnYHmwt8Z9+gRQIc",
+	"tCyaChIJK9JYL65cN6YxUnspJHDN/x9sgSRDKRES4TRFJRsxuaaJlLmY9Psxi0QvIxFngs1kL2JZH2hQ",
+	"iH6Ukj5W29O3tvXjPYHFD/pREKUkSLEEIf+Gv5bGd6sY3VZM9loAKG2EQm2t24rMdtzq7di8082t2wGa",
+	"9l5csSLC9MKSeas5unxhMa1EuCVxV6izN0qk+rDfIcwIxvHhdBAFeDoYBaPR/jA4CqNxcLA/GIYHcBge",
+	"wcAlnQSKqdwglxLCDNpNKqsuM0JjRGRpLdpE0UfGJU530ZtSZyS5hyAmHCLJ+LI/K2iMM6ASp6LzNkjY",
+	"IpAsUKwDI3ILpHH0Cmbj6UGwHw1nwSjGYYAPBoMgnIYH4WB4FL+KX211dCvEunvb0cCaVW7xXOs8Y9Nx",
+	"7eIJWvLWCLhEOFFBk4AzrQA4Tc9n3uTzN++/OMy8ife3/iqo6tuwoX+uJ1/ADDjQCLxHvyN03BR2fzAE",
+	"ddwHcHg0DfYH8TDAo/FBMBocHIzHo1EYhqHnezPGMyy9iVcUGswtC4sdC7pZLekdm4tnXZQGclqQNK5H",
+	"KlVgcse+6NN/E5df2Bei5XLvkiW+cVnvMSUzEPJZ15bViTYX1hJ0NXKzlCBxjCV+TiGZkBzgNmJZRqTT",
+	"X32XYJF8X7othaZEdrjD9+U4usNzQ7udF+g35tAjNEqLmNA5+nD668WxVwtWN63H0qiA6ISyj5vwuzCx",
+	"QtcfRIWQLCNfcRXobBLipDn60fdiogCYFrIT6/EE0uDQBZRRTr4SaRPLMzW4FL892RjSLvC1yfxea+so",
+	"cAOAGuIrB/y8DkNUdLcu14pQgWamwhNBW1FxYbajPAq6FaHd5jSA/FXnqm3wLaHmAjd7kjo5pa+0yDSl",
+	"IopAKFIzTNKCq4MwB6rsVNFbqfVqYEevTzq21NzAuoOo5a45E3LOQTwtb83xUp3ztxxyJohknDxhYy/K",
+	"SUtnSlwLQ7ZRuqyPffS9QtgKyU5yfBLAd/FkvnfKOePPaU0Ri8EJtBqEa9G1IyvAwgCzOaDQHKrhLcJu",
+	"HdWrfEeMV9xtpXq0w1mU8O+0DwZd10Y0ggpNyi3525OPW1LmaRHdgVyfRGGK4IEIqU7Gy6vjD2+OL96g",
+	"S8m4OjmjFAuBXmsSvXYKa38ElsPaE8edrl8lYHJsyVAhAM0Yt0lJzri0Kayu6sRI+ZBCAjqlc0Jt3tK7",
+	"pldVDqMJtTL8BZGJzVvennxEOWcKNh8tEhIlKrMvBMTXtOR7fmlpmSxIszey9NDZDFEmkcghIjOiZLOp",
+	"/zXdi4x/4wHOSXBdhOEwUnGv/gv2kAGjZIewqGVeSuqnlAZWpZ0ulGqJ5n0twavWtCBpqqCpwJWsju+M",
+	"s8ziqYuTFZRY/Saxpl6mQD10CYDK3C9KWRH35ozNU9CZnzCqo5PCflUAsDWVOoi+FjErUkkCK3k5HEUp",
+	"EyCkElMNMsnYNf3O5vqlehrFrKZ9r2COEiaAIlxIlmFJIpymyzbIUDyh3NkqwqhYks1KXPS6UTlcyaup",
+	"NDXZpb5aPXvX9BRHSakkGvWIUYkJRbhCipeRsGWDlOQ99KuWwCRbAmEOk2uKUID21Fkw+QYZJimJH/cm",
+	"6Jgi/QvhOOYglApiiTjkHITyRytekSKBWsvqoZ8YRxY9H+3hlETwd/tb7flez3IWwO9JBMdm3hNlMKwt",
+	"iXW8s2XAZKKtLf87znORM9mb20nlnLpIOoF/Khp2/WU1UMnVgiDOCBVODGKWYUIn38y/iqE2T3RZEAnI",
+	"PEXf5ZxkmC+/7zJPU8NQlzHVqW52H0s7t43IyvT2EONoryWT2+o2qyYRZo5xDkpREabLa1ri27Smzzr4",
+	"mHS0wvO9lj7sunme75lt68Ls+Z4FuP7wCRHcuv6BPcRctZXqjH2+4o7v2ePotl1jwSICGmMqgynHJA6G",
+	"4XC8P9xaRamR87fViho5Wbf5waOESIikCsYboj0cHtwejNaf8+bxDqnN1TIHnZCYUsC2OeeXV2qUXvGz",
+	"B93mtL9l+U6JeDPW6vRv6tA1UGmJflPuwjqNenK2afO0aj27EWio9ZokryXrkzI4ZVYktX8ayczfZWvC",
+	"pnkdhaqpSY0VXig2uqisHFA8h6AqC9lf+uwEXj4gVEicpvrBPMrVf5XWV2ap/22Muhe5Cp+cUv1iixXN",
+	"vera/U8QM46DExUaBa+xWBPMpaBeNWYOwkEYHoWveqEzQAF+D7w5o4zD7tgX0ptpxtYX9Bif68dJMW1U",
+	"ZXnqbCFgcdf2RqNBNZBQCXOTNd4DF52y03B7b9GKv2Jlu90riitUXH6rqro6HLA6zrzJN91N11XFThJF",
+	"tZrYkevIr7NHrca7oOPKocvEsknyjlB3nlteWugCX14g6L6RTOLU9aqFgmbqV7cdzCUDM9lfm2f6nvXA",
+	"jl7zrFt87B/2zUnRVyboUrW1beIu41Y9oSNBYkXoHklucNeg3u1J+CVWmoMLlHZl2HmSOoWAnK15U/qS",
+	"Td6i6xbIPIvH615RXJ7kayIjx4uahW/popnDba0Z+waESkZ1mNTO466dYQFWO7ouLoppj0OcYNNiVM4e",
+	"qOzHRMi+UrzDleYpOkz0mejv4PmiBKK723k+r613ylgKWJfW5vn89g6Wbi2bU8bhVojUPTcDiVNC79wL",
+	"ygjnjAuH2y7n/ajChh/M+2A4uC7CcHCgIP2hCsa2rc4wSa0PagpRyaBe9yKgkgnN/0e7gT8cBipEw1mN",
+	"M1b/PRiZJ1o+dcSdX+4gC09E5gKqHZSrYS6Tu2xVSFv2Fklybyp9dr+ad4og4iAD9aomaY6FWDAeu8RV",
+	"WnTrVMeuNu6wekIFmSetO1SSF+A7NIfxOaa2pt2OEUbhcDBaHyB0Ra5XlnsK3ZrkW4/thiR+G+UG0xpk",
+	"teW6drJTtGQUdqi6uu65Pfpb57QvTW2b0qmqbuXRvbuky7Ob80b2R5ZftXV2Xv2OM9rp7hPWXs642bnZ",
+	"VJ9X5TC7JJJmos0k17Wn7PFU4tzekSfmMrygdF3CUhenm7EsRE8MqxTEJDBOKgKetdmiqyjNTHnlFPRL",
+	"573PTsOx7U2FSAKIB+Px/hE6Pj4+Phl++IpP9tP/fXO2/+HqdKyenX3gb3855e//h/z3+/efFsU/8MXx",
+	"z9nFO3b29WI2+O3NIH4z/hq+vnroHzy4hOimVYUAvj3LWFP2uNE3ZiEqOJHLS4Wggeg1YG5An+q/fiqd",
+	"+M//vCov4GrXbMZVdNUpYK7hEjpj3Trxpa1jSqYvMNh+gkk0TZlN9DzfS0kE1MR19ubvcY6jBNBAp4Da",
+	"k1fxwmKx6GH9Wh/Sdq7ovzs7Of1weRoMemEvkVmq95BIDdr55WvN3rZhOdIFe4RzUgvYJt7AtuCoejHx",
+	"hr2wt68TBZlomPq2zaGdGBOOftIJBywBYURhgexoH+VMxWgEp+kSRYwK22hiMyTgHjgusdDw2M6Lvj9t",
+	"Kv+EoxjUFNtFqLfzzmJv4n1kQtqleUYPQMjXLF6aXqOOELVF5XlKTJeg/8W2EVeXq3doh1d3GJr6po5v",
+	"c2MxZ9T2+Afh/nNzP4sN4xbk5iVKsEBCYi4hVts4CsNn4287lF3eZ9R0QOxOl7diDf/9P5//cSGVktwB",
+	"RUQgYqQx3Id/PvdPFBcyYZx8Nb20HLiK/lClnEaS0V8hyR1lC1rtgwFh/FeowCcKDzlEEmIEagxiUVRw",
+	"ZRZ1X6uPsdLLfr55vPE9UWQZVulf6TRK56LmlZ5G9L+R+FGfYq729VuQpjWoT3LdyEb2gEaMa4opKNEs",
+	"Od3e1JoSpUUMAi0SkAlwNZgyQ6vEUIcBEEPc9TdvQTYvGvmNL1Q+u2/fVoSNsJKhuW6Y6y8/lI9dffhh",
+	"r5/W/Uv9M5Bnv4x503Fe4XM7r6q+3NGgJi7/Mt9VOo4Xt/XitnZyW1ctx7Pef/VTW7D+PU5sRigRSc2H",
+	"oY0ujMiV5/J1QIVTwVAGEiMVpCpHQBhFeMoKWX48UaRyk5fT9fYXH7fVx9nb4B1lU5qiVKC63mQ+OKri",
+	"Y0IRZSjHXJKoSDG39znQdzJhxTyx10x+vjz/8H3P7R8lPMh+nmLSEtrxweBuXnD0XAxcNv5YN6O3+u6O",
+	"Baeyop7LjBo32zfaUjVyB3O6AFlwKvQHXOU8LYxOQexlCFr/6quH9IWdanDEtGGJ8qaS3b4YZoRCjLBE",
+	"9eSNCZ0LmhIvpn37OyjJ9cYbTHH1xcCLPW61xxVYa4yysd0dw/zPtLWmeexgdLXW1mabswONyXXszNws",
+	"hAccycZBxLX5QYxiyIHGyg7rtlZ+vmnuu22yjFLOF8PYbhjVhyxr7KLcyqfYxUuM/hKj/3+L0Tu+yeXv",
+	"NPF6TNFxMatPAzrOxbWy1ZC+vmSxrnlSG6dvYfyppr9ag0vbzedxbIYsGC9m9q8xM6Po/35GhisFwmmK",
+	"ciYEmaZQadPKzLYX9DA1LRIaVd/3G8lWX15Ml0gfnW5D3S0CqOj+0VN/+Bef4dVWvtjoi40+xUbN3Dpp",
+	"bZdVw2/9+Xduh7i1uimsJaetVeXNCgObEf87Rg4bl/NYXbdx+Zn39iMPFheR+TKpuvzabOninPQUH5EQ",
+	"+3/OwDnpm2vJujYAPCi/MOvfD3Q80Wo0SzwndL6JgZB4Dn+QjQaRlh+hVGy20bl5/L8AAAD//9iASyXR",
+	"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/api/errors.go b/internal/worker/api/errors.go
index d3ca9b295..25b1571e3 100644
--- a/internal/worker/api/errors.go
+++ b/internal/worker/api/errors.go
@@ -155,11 +155,11 @@ func HTTPErrorHandler(echoError error, c echo.Context) {
 			apiErr := APIError(code, sec, c)
 
 			if sec.httpStatus == http.StatusInternalServerError {
-				c.Logger().Errorf("Internal server error. Internal: %s, Code: %s, OperationId: %s",
+				c.Logger().Errorf("Internal server error. Internal: %v, Code: %s, OperationId: %s",
 					internal, apiErr.Code, apiErr.OperationId)
 			} else {
-				c.Logger().Infof("%s, Code: %s, OperationId: %s", internal,
-					apiErr.Code, apiErr.OperationId)
+				c.Logger().Infof("Code: %s, OperationId: %s, Internal: %v",
+					apiErr.Code, apiErr.OperationId, internal)
 			}
 
 			if c.Request().Method == http.MethodHead {
@@ -168,7 +168,7 @@ func HTTPErrorHandler(echoError error, c echo.Context) {
 				err = c.JSON(sec.httpStatus, apiErr)
 			}
 			if err != nil {
-				c.Logger().Error(err)
+				c.Logger().Errorf("Failed to return error response: %v", err)
 			}
 		}
 	}
@@ -185,5 +185,5 @@ func HTTPErrorHandler(echoError error, c echo.Context) {
 		doResponse(apiErrorFromEchoError(he), c, he.Internal)
 		return
 	}
-	doResponse(sec, c, nil)
+	doResponse(sec, c, he.Internal)
 }
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/cases/regression-old-worker-new-composer.sh b/test/cases/regression-old-worker-new-composer.sh
index 486534257..9f4bd5fab 100644
--- a/test/cases/regression-old-worker-new-composer.sh
+++ b/test/cases/regression-old-worker-new-composer.sh
@@ -52,6 +52,8 @@ rpm -q "$WORKER_RPM"
 # run container
 WELDR_DIR="$(mktemp -d)"
 WELDR_SOCK="$WELDR_DIR/api.socket"
+DNF_DIR="$(mktemp -d)"
+DNF_SOCK="$DNF_DIR/api.sock"
 
 sudo podman pull --creds "${V2_QUAY_USERNAME}":"${V2_QUAY_PASSWORD}" \
      "quay.io/osbuild/osbuild-composer-ubi-pr:${CI_COMMIT_SHA}"
@@ -66,6 +68,8 @@ sudo podman run  \
      -v /etc/pki/entitlement:/etc/pki/entitlement:Z \
      -v "$REPOS/repositories":/usr/share/osbuild-composer/repositories:Z \
      -v "$WELDR_DIR:/run/weldr/":Z \
+     -v "$DNF_DIR:/run/osbuild-dnf-json/":Z \
+     -e OVERWRITE_CACHE_DIR="/var/cache/dnf-json" \
      -p 8700:8700 \
      "quay.io/osbuild/osbuild-composer-ubi-pr:${CI_COMMIT_SHA}" \
      --weldr-api --dnf-json --remote-worker-api \
@@ -130,3 +134,28 @@ if [[ $COMPOSE_STATUS != FINISHED ]]; then
     echo "Something went wrong with the compose. 😢"
     exit 1
 fi
+
+tee "dnf-json-request.json" <<EOF
+{
+    "command": "dump",
+    "arguments": {
+        "repos": [
+            {
+                "name": "fedora",
+                "id": "blep-2",
+                "metalink": "https://mirrors.fedoraproject.org/metalink?repo=fedora-35&arch=x86_64",
+                "check_gpg": true
+            }
+        ],
+        "arch": "x86_64",
+        "module_platform_id": "platform:f35"
+    }
+}
+EOF
+
+DNF_JSON_OUT=$(curl -d"@dnf-json-request.json" --unix-socket "$DNF_SOCK" http:/dump | jq '.packages | length')
+# expect more than 1 package
+if [ ! "$DNF_JSON_OUT" -gt "1" ]; then
+    echo "dnf-json endpoint didn't return list of packages"
+    exit 1
+fi
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,