Repository: osbuild/osbuild-composer
Pull request #2748 (2022-06-14 14:36:32)

Ground work to enable cloud uploads for Koji composes
This PR prepares the ground for supporting cloud uploads for Koji composes.

### Main changes

- `OSBuilJob` implementation is now able to handle any number of upload targets per job in a sequence.
- Upload target errors are not set in the `OSBuildJob` results any more, but in the specific target results. The reason is that now there may be more than one target in the job. All target errors are then attached to the job error as `details` and an appropriate job error is set.
- Uploading back to the worker server is now done using an explicit upload target `org.osbuild.worker.server`, instead of setting the `OSBuildJob.ImageName`. `OSBuildJob.ImageName` was removed. No backward compatibility is handled in this case, because this functionality is used only by Weldr API and there it is expected that the server and worker will be of the same version. I considered using the `org.osbuild.local` target, but intentionally didn't do so. The reason is that it contained many useless fields and in order to delete them and use it, I would have to keep backward compatibility layer to support reading stored compose results in the on-premise store. By using a new target, this is not needed. Also a more (IMHO) self-explanatory target name could be used.
- Related to the previous point, the **composes submitted by Weldr API with upload to cloud already use multiple targets in the job** (2 - upload back to worker server and to the cloud).
- Export `Filename` was moved from target-specific Options to the Target itself. In reality, the export filename is specific to the export used for the target and must be defined for any target regardless of its options type. Backward compatibility is handled on the JSON (Un)Marshal level.
- The `Exports` have been moved from the `OSBuildJob` to the Target. In reality, the export is specific to the target. Backward compatibility is handled on the JSON (Un)Marshal level. Each Target can specify only a single export. Only a single common export is still used for all targets. **Technically, the `OSBuildJob` implementation is able to handle different export per target**, however no API implementation does that at this moment.
- The backward compatibility code handling stream-optimized conversion for VMDK is deleted, because all deployments have been already updated and it is not needed for Weldr due to the expectation of server and worker being of the same version. Also deleting it made the rework to support multiple upload targets simpler.

### Miscellaneous changes

- Target names are defined as constants, instead of using string literals everywhere.
- Some unused functions and structure fields were deleted.
- some variable names in the `OSBuildJob` implementation were renamed to better express their purpose.

### What's left (still WIP and not part of this PR)

- Modify Cloud API to allow upload options for Koji composes.
- Extend the `koji.sh` test case to test upload to cloud for Koji composes.

This pull request includes:

- [x] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`

Information regarding our GitLab pipeline (Schutzbot):

CI will not be ran automatically if WIP label is applied or the PR is in DRAFT state, instead only
a link will be provided to the pipeline which can then be triggered manually if desired. To run the
CI automatically either switch the PR to ready or apply WIP+test label.

Outside contributors need manual approval from one of the osbuild-composer maintainers.

Schutzbot will only be triggered if all Tests jobs in GitHub workflow succeed.
-->


achilleas-k: > warning This PR introduces changes in at least one manifest (when comparing PR HEAD [693a3b5](https://github.com/osbuild/osbuild-composer/commit/693a3b5d4da3e5adbc6e0a743dd5d930e8185649) with the main merge-base [3fcb737](https://github.com/osbuild/osbuild-composer/commit/3fcb737243b742fa9143cd9df068044eeff54eb1)). Please review the changes.

Seems something went wrong with one of the repositories for RHEL 8 aarch64 and it affected the packages for the AMI.
```
Failed to add groups file for repository: 29bdead0aef9110bb7ba53940a6a0bf7207adbe61e7cd48a23451bcdb06ba628 - ERROR: Parser error at line:1 col:0
    not well-formed (invalid token)
```

I've restarted the job to make sure it's all fine.

Patch:
diff --git a/cmd/cloud-cleaner/main.go b/cmd/cloud-cleaner/main.go
deleted file mode 100644
index b87245d82..000000000
--- a/cmd/cloud-cleaner/main.go
+++ /dev/null
@@ -1,176 +0,0 @@
-// +build integration
-
-package main
-
-import (
-	"context"
-	"crypto/sha256"
-	"fmt"
-	"log"
-	"os"
-	"sync"
-
-	"github.com/Azure/go-autorest/autorest/azure/auth"
-
-	"github.com/osbuild/osbuild-composer/internal/boot/azuretest"
-	"github.com/osbuild/osbuild-composer/internal/cloud/gcp"
-	"github.com/osbuild/osbuild-composer/internal/test"
-)
-
-func cleanupGCP(testID string, wg *sync.WaitGroup) {
-	defer wg.Done()
-
-	log.Println("[GCP] Running clean up")
-
-	GCPRegionName, ok := os.LookupEnv("GCP_REGION")
-	if !ok {
-		log.Println("[GCP] Error: 'GCP_REGION' is not set in the environment.")
-		return
-	}
-	GCPBucket, ok := os.LookupEnv("GCP_BUCKET")
-	if !ok {
-		log.Println("[GCP] Error: 'GCP_BUCKET' is not set in the environment.")
-		return
-	}
-	// max 62 characters
-	// Must be a match of regex '[a-z](?:[-a-z0-9]{0,61}[a-z0-9])?|[1-9][0-9]{0,19}'
-	// use sha224sum to get predictable testID without invalid characters
-	testIDhash := fmt.Sprintf("%x", sha256.Sum224([]byte(testID)))
-
-	// Resource names to clean up
-	GCPInstance := fmt.Sprintf("vm-%s", testIDhash)
-	GCPImage := fmt.Sprintf("image-%s", testIDhash)
-
-	// It does not matter if there was any error. If the credentials file was
-	// read successfully then 'creds' should be non-nil, otherwise it will be
-	// nil. Both values are acceptable for creating a new "GCP" instance.
-	// If 'creds' is nil, then GCP library will try to authenticate using
-	// the instance permissions.
-	creds, err := gcp.GetCredentialsFromEnv()
-	if err != nil {
-		log.Printf("[GCP] Error: %v. This may not be an issue.", err)
-	}
-
-	// If this fails, there is no point in continuing
-	g, err := gcp.New(creds)
-	if err != nil {
-		log.Printf("[GCP] Error: %v", err)
-		return
-	}
-
-	ctx := context.Background()
-
-	// Try to delete potentially running instance
-	// api.sh chooses a random GCP Zone from regions which names start with the
-	// `GCPRegionName` value. Since we don't know which one it is, iterate over
-	// all Regions and Zones in them and try to delete the instance. Unless the
-	// instance has set "VmDnsSetting:ZonalOnly", which we don't do, this is
-	// safe and the instance name must be unique for the whole GCP project.
-	GCPRegions, err := g.ComputeRegionsList(ctx, fmt.Sprintf("name:%s* AND status=UP", GCPRegionName))
-	if err != nil {
-		log.Printf("[GCP] Error: Failed to list GCE regions starting with %q: %v", GCPRegionName, err)
-	}
-	for _, GCPRegion := range GCPRegions {
-		GCPZones, err := g.ComputeUpZonesInRegion(ctx, GCPRegion)
-		if err != nil {
-			log.Printf("[GCP] Error: Failed to get available Zones for the '%s' Region: %v", GCPRegionName, err)
-			break
-		}
-		for _, GCPZone := range GCPZones {
-			log.Printf("[GCP] 🧹 Deleting VM instance %s in %s. "+
-				"This should fail if the test succeeded.", GCPInstance, GCPZone.GetName())
-			err = g.ComputeInstanceDelete(ctx, GCPZone.GetName(), GCPInstance)
-			if err == nil {
-				// If an instance with the given name was successfully deleted in one of the Zones, we are done.
-				break
-			} else {
-				log.Printf("[GCP] Error: %v", err)
-			}
-		}
-	}
-
-	// Try to find the potentially uploaded Storage objects using custom metadata
-	objects, err := g.StorageListObjectsByMetadata(ctx, GCPBucket, map[string]string{gcp.MetadataKeyImageName: GCPImage})
-	if err != nil {
-		log.Printf("[GCP] Error: %v", err)
-	}
-	for _, obj := range objects {
-		if err = g.StorageObjectDelete(ctx, obj.Bucket, obj.Name); err != nil {
-			log.Printf("[GCP] Error: %v", err)
-		}
-		log.Printf("[GCP] 🧹 Deleted object %s/%s related to build of image %s", obj.Bucket, obj.Name, GCPImage)
-	}
-
-	// Try to delete the imported image
-	log.Printf("[GCP] 🧹 Deleting image %s. This should fail if the test succeeded.", GCPImage)
-	err = g.ComputeImageDelete(ctx, GCPImage)
-	if err != nil {
-		log.Printf("[GCP] Error: %v", err)
-	}
-}
-
-func cleanupAzure(testID string, wg *sync.WaitGroup) {
-	defer wg.Done()
-
-	log.Println("[Azure] Running clean up")
-
-	// Load Azure credentials
-	creds, err := azuretest.GetAzureCredentialsFromEnv()
-	if err != nil {
-		log.Printf("[Azure] Error: %v", err)
-		return
-	}
-	if creds == nil {
-		log.Println("[Azure] Error: empty credentials")
-		return
-	}
-
-	// Delete the vhd image
-	imageName := "image-" + testID + ".vhd"
-	log.Println("[Azure] Deleting image. This should fail if the test succeeded.")
-	err = azuretest.DeleteImageFromAzure(creds, imageName)
-	if err != nil {
-		log.Printf("[Azure] Error: %v", err)
-	}
-
-	// Delete all remaining resources (see the full list in the CleanUpBootedVM function)
-	log.Println("[Azure] Cleaning up booted VM. This should fail if the test succeeded.")
-	parameters := azuretest.NewDeploymentParameters(creds, imageName, testID, "")
-	clientCredentialsConfig := auth.NewClientCredentialsConfig(creds.ClientID, creds.ClientSecret, creds.TenantID)
-	authorizer, err := clientCredentialsConfig.Authorizer()
-	if err != nil {
-		log.Printf("[Azure] Error: %v", err)
-		return
-	}
-
-	err = azuretest.CleanUpBootedVM(creds, parameters, authorizer, testID)
-	if err != nil {
-		log.Printf("[Azure] Error: %v", err)
-	}
-}
-
-func main() {
-	log.Println("Running a cloud cleanup")
-	var wg sync.WaitGroup
-
-	// Currently scheduled cloud-cleaner supports Azure only.
-	// In case of scheduled cleanup get testID from env and run Azure cleanup.
-	// If it's empty generate it and cleanup both GCP and Azure.
-	testID := os.Getenv("TEST_ID")
-	if testID == "" {
-		testID, err := test.GenerateCIArtifactName("")
-		if err != nil {
-			log.Fatalf("Failed to get testID: %v", err)
-		}
-		log.Printf("TEST_ID=%s", testID)
-		wg.Add(2)
-		go cleanupAzure(testID, &wg)
-		go cleanupGCP(testID, &wg)
-		wg.Wait()
-	} else {
-		wg.Add(1)
-		go cleanupAzure(testID, &wg)
-		wg.Wait()
-	}
-
-}
diff --git a/cmd/osbuild-store-dump/main.go b/cmd/osbuild-store-dump/main.go
index f6c323680..fd3f54a3d 100644
--- a/cmd/osbuild-store-dump/main.go
+++ b/cmd/osbuild-store-dump/main.go
@@ -106,7 +106,6 @@ func main() {
 	}
 	awsTarget := target.NewAWSTarget(
 		&target.AWSTargetOptions{
-			Filename:        "image.ami",
 			Region:          "far-away-1",
 			AccessKeyID:     "MyKey",
 			SecretAccessKey: "MySecret",
@@ -117,6 +116,7 @@ func main() {
 	awsTarget.Uuid = id1
 	awsTarget.ImageName = "My Image"
 	awsTarget.Created = time.Now()
+	awsTarget.OsbuildArtifact.ExportFilename = "image.ami"
 
 	d := fedora.NewF35()
 	a, err := d.GetArch(distro.X86_64ArchName)
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 3ad5b9f43..632522ece 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -175,58 +175,26 @@ func validateResult(result *worker.OSBuildJobResult, jobID string) {
 	result.Success = true
 }
 
-func uploadToS3(a *awscloud.AWS, outputDirectory, exportPath, bucket, key, filename string, osbuildJobResult *worker.OSBuildJobResult, streamOptimized bool, streamOptimizedPath string) (err error) {
+func uploadToS3(a *awscloud.AWS, outputDirectory, exportPath, bucket, key, filename string) (string, *clienterrors.Error) {
 	imagePath := path.Join(outputDirectory, exportPath, filename)
 
-	// TODO: delete the stream-optimized handling after "some" time (kept for backward compatibility)
-	// *** SPECIAL VMDK HANDLING START ***
-	// Upload the VMDK image as stream-optimized.
-	// The VMDK conversion is applied only when the job was submitted by Weldr API,
-	// therefore we need to do the conversion here explicitly if it was not done.
-	if streamOptimized {
-		// If the streamOptimizedPath is empty, the conversion was not done
-		if streamOptimizedPath == "" {
-			var f *os.File
-			f, err = vmware.OpenAsStreamOptimizedVmdk(imagePath)
-			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
-			}
-			streamOptimizedPath = f.Name()
-			f.Close()
-		}
-		// Replace the original file by the stream-optimized one
-		err = os.Rename(streamOptimizedPath, imagePath)
-		if err != nil {
-			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-			return nil
-		}
-	}
-	// *** SPECIAL VMDK HANDLING END ***
-
 	if key == "" {
 		key = uuid.New().String()
 	}
 	key += "-" + filename
 
-	_, err = a.Upload(imagePath, bucket, key)
+	_, err := a.Upload(imagePath, bucket, key)
 	if err != nil {
-		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-		return
+		return "", clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+
 	}
 
 	url, err := a.S3ObjectPresignedURL(bucket, key)
 	if err != nil {
-		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-		return
+		return "", clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
 	}
 
-	osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAWSS3TargetResult(&target.AWSS3TargetResultOptions{URL: url}))
-
-	osbuildJobResult.Success = true
-	osbuildJobResult.UploadStatus = "success"
-
-	return
+	return url, nil
 }
 
 func (impl *OSBuildJobImpl) Run(job worker.Job) error {
@@ -271,26 +239,26 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 	}
 
 	// Read the job specification
-	var args worker.OSBuildJob
-	err = job.Args(&args)
+	var jobArgs worker.OSBuildJob
+	err = job.Args(&jobArgs)
 	if err != nil {
 		return err
 	}
 
 	// In case the manifest is empty, try to get it from dynamic args
-	if len(args.Manifest) == 0 {
+	if len(jobArgs.Manifest) == 0 {
 		if job.NDynamicArgs() > 0 {
 			var manifestJR worker.ManifestJobByIDResult
 			if job.NDynamicArgs() == 1 {
 				// Classic case of a compose request with the ManifestJobByID job as the single dependency
 				err = job.DynamicArgs(0, &manifestJR)
-			} else if job.NDynamicArgs() > 1 && args.ManifestDynArgsIdx != nil {
+			} else if job.NDynamicArgs() > 1 && jobArgs.ManifestDynArgsIdx != nil {
 				// Case when the job has multiple dependencies, but the manifest is not part of the static job arguments,
 				// but rather in the dynamic arguments (e.g. from ManifestJobByID job).
-				if *args.ManifestDynArgsIdx > job.NDynamicArgs()-1 {
+				if *jobArgs.ManifestDynArgsIdx > job.NDynamicArgs()-1 {
 					panic("ManifestDynArgsIdx is out of range of the number of dynamic job arguments")
 				}
-				err = job.DynamicArgs(*args.ManifestDynArgsIdx, &manifestJR)
+				err = job.DynamicArgs(*jobArgs.ManifestDynArgsIdx, &manifestJR)
 			}
 			if err != nil {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorParsingDynamicArgs, "Error parsing dynamic args")
@@ -302,10 +270,10 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorManifestDependency, "Manifest dependency failed")
 				return nil
 			}
-			args.Manifest = manifestJR.Manifest
+			jobArgs.Manifest = manifestJR.Manifest
 		}
 
-		if len(args.Manifest) == 0 {
+		if len(jobArgs.Manifest) == 0 {
 			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorEmptyManifest, "Job has no manifest")
 			return nil
 		}
@@ -328,28 +296,17 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 	}
 
 	// copy pipeline info to the result
-	osbuildJobResult.PipelineNames = args.PipelineNames
+	osbuildJobResult.PipelineNames = jobArgs.PipelineNames
 
-	// The specification allows multiple upload targets because it is an array, but we don't support it.
-	// Return an error to osbuild-composer.
-	if len(args.Targets) > 1 {
-		logrus.Warnf("The job specification contains more than one upload target. This is not supported any more. " +
-			"This might indicate a deployment of incompatible osbuild-worker and osbuild-composer versions.")
-		return nil
-	}
-
-	exports := args.Exports
+	// get exports for all job's targets
+	exports := jobArgs.OsbuildExports()
 	if len(exports) == 0 {
-		// job did not define exports, likely coming from an older version of composer
-		// fall back to default "assembler"
-		exports = []string{"assembler"}
-	} else if len(exports) > 1 {
-		// this worker only supports returning one (1) export
-		return fmt.Errorf("at most one build artifact can be exported")
+		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "no osbuild export specified for the job")
+		return nil
 	}
 
 	// Run osbuild and handle two kinds of errors
-	osbuildJobResult.OSBuildOutput, err = RunOSBuild(args.Manifest, impl.Store, outputDirectory, exports, os.Stderr)
+	osbuildJobResult.OSBuildOutput, err = RunOSBuild(jobArgs.Manifest, impl.Store, outputDirectory, exports, os.Stderr)
 	// First handle the case when "running" osbuild failed
 	if err != nil {
 		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorBuildJob, "osbuild build failed")
@@ -384,56 +341,40 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 		return nil
 	}
 
-	// TODO: delete the stream-optimized handling after "some" time (kept for backward compatibility)
-	streamOptimizedPath := ""
-
-	// NOTE: Currently OSBuild supports multiple exports, but this isn't used
-	// by any of the image types and it can't be specified during the request.
-	// Use the first (and presumably only) export for the imagePath.
-	exportPath := exports[0]
-	if osbuildJobResult.OSBuildOutput.Success && args.ImageName != "" {
-		var f *os.File
-		imagePath := path.Join(outputDirectory, exportPath, args.ImageName)
-		// TODO: delete the stream-optimized handling after "some" time (kept for backward compatibility)
-		if args.StreamOptimized {
-			f, err = vmware.OpenAsStreamOptimizedVmdk(imagePath)
+	for _, jobTarget := range jobArgs.Targets {
+		var targetResult *target.TargetResult
+		switch targetOptions := jobTarget.Options.(type) {
+		case *target.WorkerServerTargetOptions:
+			targetResult = target.NewWorkerServerTargetResult()
+			var f *os.File
+			imagePath := path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename)
+			f, err = os.Open(imagePath)
 			if err != nil {
-				return err
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, err.Error())
+				break
 			}
-			streamOptimizedPath = f.Name()
-		} else {
-			f, err = os.Open(imagePath)
+			defer f.Close()
+			err = job.UploadArtifact(jobTarget.ImageName, f)
 			if err != nil {
-				return err
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
-		}
-		err = job.UploadArtifact(args.ImageName, f)
-		if err != nil {
-			return err
-		}
-		f.Close()
-	}
 
-	if len(args.Targets) == 0 {
-		// There is no upload target, mark this job a success.
-		osbuildJobResult.Success = true
-		osbuildJobResult.UploadStatus = "success"
-	} else if len(args.Targets) == 1 {
-		switch options := args.Targets[0].Options.(type) {
 		case *target.VMWareTargetOptions:
+			targetResult = target.NewVMWareTargetResult()
 			credentials := vmware.Credentials{
-				Username:   options.Username,
-				Password:   options.Password,
-				Host:       options.Host,
-				Cluster:    options.Cluster,
-				Datacenter: options.Datacenter,
-				Datastore:  options.Datastore,
+				Username:   targetOptions.Username,
+				Password:   targetOptions.Password,
+				Host:       targetOptions.Host,
+				Cluster:    targetOptions.Cluster,
+				Datacenter: targetOptions.Datacenter,
+				Datastore:  targetOptions.Datastore,
 			}
 
 			tempDirectory, err := ioutil.TempDir(impl.Output, job.Id().String()+"-vmware-*")
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
 			defer func() {
@@ -444,193 +385,184 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			}()
 
 			// create a symlink so that uploaded image has the name specified by user
-			imageName := args.Targets[0].ImageName + ".vmdk"
+			imageName := jobTarget.ImageName + ".vmdk"
 			imagePath := path.Join(tempDirectory, imageName)
 
-			// New version of composer is already generating manifest with stream-optimized VMDK and is not setting
-			// the args.StreamOptimized option. In such case, the image itself is already stream optimized.
-			// Simulate the case as if it was converted by the worker. This makes it simpler to reuse the rest of
-			// the existing code below.
-			if !args.StreamOptimized {
-				streamOptimizedPath = path.Join(outputDirectory, exportPath, options.Filename)
-			}
-
-			err = os.Symlink(streamOptimizedPath, imagePath)
+			exportedImagePath := path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename)
+			err = os.Symlink(exportedImagePath, imagePath)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
 			err = vmware.UploadImage(credentials, imagePath)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.AWSTargetOptions:
-			a, err := impl.getAWS(options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
+			targetResult = target.NewAWSTargetResult(nil)
+			a, err := impl.getAWS(targetOptions.Region, targetOptions.AccessKeyID, targetOptions.SecretAccessKey, targetOptions.SessionToken)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
-			key := options.Key
+			key := targetOptions.Key
 			if key == "" {
 				key = uuid.New().String()
 			}
 
-			bucket := options.Bucket
+			bucket := targetOptions.Bucket
 			if impl.AWSBucket != "" {
 				bucket = impl.AWSBucket
 			}
-			_, err = a.Upload(path.Join(outputDirectory, exportPath, options.Filename), bucket, key)
+			_, err = a.Upload(path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename), bucket, key)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 
-			ami, err := a.Register(args.Targets[0].ImageName, bucket, key, options.ShareWithAccounts, common.CurrentArch())
+			ami, err := a.Register(jobTarget.ImageName, bucket, key, targetOptions.ShareWithAccounts, common.CurrentArch())
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, err.Error())
+				break
 			}
 
 			if ami == nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, "No ami returned")
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, "No ami returned")
+				break
 			}
-
-			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAWSTargetResult(&target.AWSTargetResultOptions{
+			targetResult.Options = &target.AWSTargetResultOptions{
 				Ami:    *ami,
-				Region: options.Region,
-			}))
+				Region: targetOptions.Region,
+			}
 
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.AWSS3TargetOptions:
-			a, bucket, err := impl.getAWSForS3Target(options)
+			targetResult = target.NewAWSS3TargetResult(nil)
+			a, bucket, err := impl.getAWSForS3Target(targetOptions)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
-			err = uploadToS3(a, outputDirectory, exportPath, bucket, options.Key, options.Filename, osbuildJobResult, args.StreamOptimized, streamOptimizedPath)
-			if err != nil {
-				return nil
+			url, targetError := uploadToS3(a, outputDirectory, jobTarget.OsbuildArtifact.ExportName, bucket, targetOptions.Key, jobTarget.OsbuildArtifact.ExportFilename)
+			if targetError != nil {
+				targetResult.TargetError = targetError
+				break
 			}
+			targetResult.Options = &target.AWSS3TargetResultOptions{URL: url}
+
 		case *target.AzureTargetOptions:
-			azureStorageClient, err := azure.NewStorageClient(options.StorageAccount, options.StorageAccessKey)
+			targetResult = target.NewAzureTargetResult()
+			azureStorageClient, err := azure.NewStorageClient(targetOptions.StorageAccount, targetOptions.StorageAccessKey)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return err
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
 			// Azure cannot create an image from a blob without .vhd extension
-			blobName := azure.EnsureVHDExtension(args.Targets[0].ImageName)
+			blobName := azure.EnsureVHDExtension(jobTarget.ImageName)
 			metadata := azure.BlobMetadata{
-				StorageAccount: options.StorageAccount,
-				ContainerName:  options.Container,
+				StorageAccount: targetOptions.StorageAccount,
+				ContainerName:  targetOptions.Container,
 				BlobName:       blobName,
 			}
 
 			const azureMaxUploadGoroutines = 4
 			err = azureStorageClient.UploadPageBlob(
 				metadata,
-				path.Join(outputDirectory, exportPath, options.Filename),
+				path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename),
 				azureMaxUploadGoroutines,
 			)
 
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.GCPTargetOptions:
+			targetResult = target.NewGCPTargetResult(nil)
 			ctx := context.Background()
 
-			g, err := impl.getGCP(options.Credentials)
+			g, err := impl.getGCP(targetOptions.Credentials)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
-			logWithId.Infof("[GCP] 🚀 Uploading image to: %s/%s", options.Bucket, options.Object)
-			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, exportPath, options.Filename),
-				options.Bucket, options.Object, map[string]string{gcp.MetadataKeyImageName: args.Targets[0].ImageName})
+			logWithId.Infof("[GCP] 🚀 Uploading image to: %s/%s", targetOptions.Bucket, targetOptions.Object)
+			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename),
+				targetOptions.Bucket, targetOptions.Object, map[string]string{gcp.MetadataKeyImageName: jobTarget.ImageName})
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 
-			logWithId.Infof("[GCP] 📥 Importing image into Compute Engine as '%s'", args.Targets[0].ImageName)
+			logWithId.Infof("[GCP] 📥 Importing image into Compute Engine as '%s'", jobTarget.ImageName)
 
-			_, importErr := g.ComputeImageInsert(ctx, options.Bucket, options.Object, args.Targets[0].ImageName, []string{options.Region}, gcp.GuestOsFeaturesByDistro(options.Os))
+			_, importErr := g.ComputeImageInsert(ctx, targetOptions.Bucket, targetOptions.Object, jobTarget.ImageName, []string{targetOptions.Region}, gcp.GuestOsFeaturesByDistro(targetOptions.Os))
 			if importErr == nil {
 				logWithId.Infof("[GCP] 🎉 Image import finished successfully")
 			}
 
 			// Cleanup storage before checking for errors
-			logWithId.Infof("[GCP] 🧹 Deleting uploaded image file: %s/%s", options.Bucket, options.Object)
-			if err = g.StorageObjectDelete(ctx, options.Bucket, options.Object); err != nil {
+			logWithId.Infof("[GCP] 🧹 Deleting uploaded image file: %s/%s", targetOptions.Bucket, targetOptions.Object)
+			if err = g.StorageObjectDelete(ctx, targetOptions.Bucket, targetOptions.Object); err != nil {
 				logWithId.Errorf("[GCP] Encountered error while deleting object: %v", err)
 			}
 
 			// check error from ComputeImageInsert()
 			if importErr != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, importErr.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, importErr.Error())
+				break
 			}
-			logWithId.Infof("[GCP] 💿 Image URL: %s", g.ComputeImageURL(args.Targets[0].ImageName))
+			logWithId.Infof("[GCP] 💿 Image URL: %s", g.ComputeImageURL(jobTarget.ImageName))
 
-			if len(options.ShareWithAccounts) > 0 {
-				logWithId.Infof("[GCP] 🔗 Sharing the image with: %+v", options.ShareWithAccounts)
-				err = g.ComputeImageShare(ctx, args.Targets[0].ImageName, options.ShareWithAccounts)
+			if len(targetOptions.ShareWithAccounts) > 0 {
+				logWithId.Infof("[GCP] 🔗 Sharing the image with: %+v", targetOptions.ShareWithAccounts)
+				err = g.ComputeImageShare(ctx, jobTarget.ImageName, targetOptions.ShareWithAccounts)
 				if err != nil {
-					osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorSharingTarget, err.Error())
-					return nil
+					targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorSharingTarget, err.Error())
+					break
 				}
 			}
-
-			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewGCPTargetResult(&target.GCPTargetResultOptions{
-				ImageName: args.Targets[0].ImageName,
+			targetResult.Options = &target.GCPTargetResultOptions{
+				ImageName: jobTarget.ImageName,
 				ProjectID: g.GetProjectID(),
-			}))
+			}
 
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.AzureImageTargetOptions:
+			targetResult = target.NewAzureImageTargetResult(nil)
 			ctx := context.Background()
 
 			if impl.AzureCreds == nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorSharingTarget, "osbuild job has org.osbuild.azure.image target but this worker doesn't have azure credentials")
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorSharingTarget, "osbuild job has org.osbuild.azure.image target but this worker doesn't have azure credentials")
+				break
 			}
 
-			c, err := azure.NewClient(*impl.AzureCreds, options.TenantID)
+			c, err := azure.NewClient(*impl.AzureCreds, targetOptions.TenantID)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, err.Error())
+				break
 			}
 			logWithId.Info("[Azure] 🔑 Logged in Azure")
 
 			storageAccountTag := azure.Tag{
 				Name:  "imageBuilderStorageAccount",
-				Value: fmt.Sprintf("location=%s", options.Location),
+				Value: fmt.Sprintf("location=%s", targetOptions.Location),
 			}
 
 			storageAccount, err := c.GetResourceNameByTag(
 				ctx,
-				options.SubscriptionID,
-				options.ResourceGroup,
+				targetOptions.SubscriptionID,
+				targetOptions.ResourceGroup,
 				storageAccountTag,
 			)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("searching for a storage account failed: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("searching for a storage account failed: %v", err))
+				break
 			}
 
 			if storageAccount == "" {
@@ -640,34 +572,34 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 
 				err := c.CreateStorageAccount(
 					ctx,
-					options.SubscriptionID,
-					options.ResourceGroup,
+					targetOptions.SubscriptionID,
+					targetOptions.ResourceGroup,
 					storageAccount,
-					options.Location,
+					targetOptions.Location,
 					storageAccountTag,
 				)
 				if err != nil {
-					osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("creating a new storage account failed: %v", err))
-					return nil
+					targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("creating a new storage account failed: %v", err))
+					break
 				}
 			}
 
 			logWithId.Info("[Azure] 🔑📦 Retrieving a storage account key")
 			storageAccessKey, err := c.GetStorageAccountKey(
 				ctx,
-				options.SubscriptionID,
-				options.ResourceGroup,
+				targetOptions.SubscriptionID,
+				targetOptions.ResourceGroup,
 				storageAccount,
 			)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("retrieving the storage account key failed: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("retrieving the storage account key failed: %v", err))
+				break
 			}
 
 			azureStorageClient, err := azure.NewStorageClient(storageAccount, storageAccessKey)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("creating the storage client failed: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("creating the storage client failed: %v", err))
+				break
 			}
 
 			storageContainer := "imagebuilder"
@@ -675,12 +607,12 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			logWithId.Info("[Azure] 📦 Ensuring that we have a storage container")
 			err = azureStorageClient.CreateStorageContainerIfNotExist(ctx, storageAccount, storageContainer)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("cannot create a storage container: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("cannot create a storage container: %v", err))
+				break
 			}
 
 			// Azure cannot create an image from a blob without .vhd extension
-			blobName := azure.EnsureVHDExtension(args.Targets[0].ImageName)
+			blobName := azure.EnsureVHDExtension(jobTarget.ImageName)
 
 			logWithId.Info("[Azure] ⬆ Uploading the image")
 			err = azureStorageClient.UploadPageBlob(
@@ -689,57 +621,54 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 					ContainerName:  storageContainer,
 					BlobName:       blobName,
 				},
-				path.Join(outputDirectory, exportPath, options.Filename),
+				path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename),
 				azure.DefaultUploadThreads,
 			)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, fmt.Sprintf("uploading the image failed: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, fmt.Sprintf("uploading the image failed: %v", err))
+				break
 			}
 
 			logWithId.Info("[Azure] 📝 Registering the image")
 			err = c.RegisterImage(
 				ctx,
-				options.SubscriptionID,
-				options.ResourceGroup,
+				targetOptions.SubscriptionID,
+				targetOptions.ResourceGroup,
 				storageAccount,
 				storageContainer,
 				blobName,
-				args.Targets[0].ImageName,
-				options.Location,
+				jobTarget.ImageName,
+				targetOptions.Location,
 			)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, fmt.Sprintf("registering the image failed: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorImportingImage, fmt.Sprintf("registering the image failed: %v", err))
+				break
 			}
-
 			logWithId.Info("[Azure] 🎉 Image uploaded and registered!")
+			targetResult.Options = &target.AzureImageTargetResultOptions{
+				ImageName: jobTarget.ImageName,
+			}
 
-			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAzureImageTargetResult(&target.AzureImageTargetResultOptions{
-				ImageName: args.Targets[0].ImageName,
-			}))
-
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.KojiTargetOptions:
-			kojiServerURL, err := url.Parse(options.Server)
+			targetResult = target.NewKojiTargetResult(nil)
+			kojiServerURL, err := url.Parse(targetOptions.Server)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to parse Koji server URL: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to parse Koji server URL: %v", err))
+				break
 			}
 
 			kojiServer, exists := impl.KojiServers[kojiServerURL.Hostname()]
 			if !exists {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("Koji server has not been configured: %s", kojiServerURL.Hostname()))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("Koji server has not been configured: %s", kojiServerURL.Hostname()))
+				break
 			}
 
 			kojiTransport := koji.CreateKojiTransport(kojiServer.relaxTimeoutFactor)
 
-			kojiAPI, err := koji.NewFromGSSAPI(options.Server, &kojiServer.creds, kojiTransport)
+			kojiAPI, err := koji.NewFromGSSAPI(targetOptions.Server, &kojiServer.creds, kojiTransport)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to authenticate with Koji server %q: %v", kojiServerURL.Hostname(), err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, fmt.Sprintf("failed to authenticate with Koji server %q: %v", kojiServerURL.Hostname(), err))
+				break
 			}
 			logWithId.Infof("[Koji] 🔑 Authenticated with %q", kojiServerURL.Hostname())
 			defer func() {
@@ -749,107 +678,116 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				}
 			}()
 
-			file, err := os.Open(path.Join(outputDirectory, exportPath, options.Filename))
+			file, err := os.Open(path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename))
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorKojiBuild, fmt.Sprintf("failed to open the image for reading: %v", err))
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorKojiBuild, fmt.Sprintf("failed to open the image for reading: %v", err))
+				break
 			}
 			defer file.Close()
 
 			logWithId.Info("[Koji] ⬆ Uploading the image")
-			imageHash, imageSize, err := kojiAPI.Upload(file, options.UploadDirectory, args.Targets[0].ImageName)
+			imageHash, imageSize, err := kojiAPI.Upload(file, targetOptions.UploadDirectory, jobTarget.ImageName)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 			logWithId.Info("[Koji] 🎉 Image successfully uploaded")
-
-			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewKojiTargetResult(&target.KojiTargetResultOptions{
+			targetResult.Options = &target.KojiTargetResultOptions{
 				ImageMD5:  imageHash,
 				ImageSize: imageSize,
-			}))
+			}
 
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
 		case *target.OCITargetOptions:
+			targetResult = target.NewOCITargetResult(nil)
 			// create an ociClient uploader with a valid storage client
 			var ociClient oci.Client
 			ociClient, err = oci.NewClient(&oci.ClientParams{
-				User:        options.User,
-				Region:      options.Region,
-				Tenancy:     options.Tenancy,
-				Fingerprint: options.Fingerprint,
-				PrivateKey:  options.PrivateKey,
+				User:        targetOptions.User,
+				Region:      targetOptions.Region,
+				Tenancy:     targetOptions.Tenancy,
+				Fingerprint: targetOptions.Fingerprint,
+				PrivateKey:  targetOptions.PrivateKey,
 			})
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 			logWithId.Info("[OCI] 🔑 Logged in OCI")
 			logWithId.Info("[OCI] ⬆ Uploading the image")
-			file, err := os.Open(path.Join(outputDirectory, exportPath, options.FileName))
+			file, err := os.Open(path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename))
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 			defer file.Close()
 			i, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
 			imageID, err := ociClient.Upload(
 				fmt.Sprintf("osbuild-upload-%d", i),
-				options.Bucket,
-				options.Namespace,
+				targetOptions.Bucket,
+				targetOptions.Namespace,
 				file,
-				options.Compartment,
-				args.Targets[0].ImageName,
+				targetOptions.Compartment,
+				jobTarget.ImageName,
 			)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 			logWithId.Info("[OCI] 🎉 Image uploaded and registered!")
-
-			osbuildJobResult.TargetResults = append(
-				osbuildJobResult.TargetResults,
-				target.NewOCITargetResult(&target.OCITargetResultOptions{ImageID: imageID}),
-			)
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
+			targetResult.Options = &target.OCITargetResultOptions{ImageID: imageID}
 
 		case *target.ContainerTargetOptions:
-			destination := args.Targets[0].ImageName
+			targetResult = target.NewContainerTargetResult()
+			destination := jobTarget.ImageName
 
 			logWithId.Printf("[container] ⬆ Uploading the image to %s", destination)
 
 			ctx := context.Background()
 			client, err := container.NewClient(destination)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
+				break
 			}
 
-			client.Auth.Username = options.Username
-			client.Auth.Password = options.Password
+			client.Auth.Username = targetOptions.Username
+			client.Auth.Password = targetOptions.Password
 
-			if options.TlsVerify != nil {
-				client.TlsVerify = *options.TlsVerify
+			if targetOptions.TlsVerify != nil {
+				client.TlsVerify = *targetOptions.TlsVerify
 			}
 
-			sourcePath := path.Join(outputDirectory, exportPath, options.Filename)
+			sourcePath := path.Join(outputDirectory, jobTarget.OsbuildArtifact.ExportName, jobTarget.OsbuildArtifact.ExportFilename)
 
 			// TODO: get the container type from the metadata of the osbuild job
 			sourceRef := fmt.Sprintf("oci-archive:%s", sourcePath)
 
 			digest, err := client.UploadImage(ctx, sourceRef, "")
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
-				return nil
+				targetResult.TargetError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				break
 			}
 			logWithId.Printf("[container] 🎉 Image uploaded (%s)!", digest.String())
 
 		default:
-			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTarget, fmt.Sprintf("invalid target type: %s", args.Targets[0].Name))
+			// TODO: we may not want to return completely here with multiple targets, because then no TargetErrors will be added to the JobError details
+			// Nevertheless, all target errors will be still in the OSBuildJobResult.
+			osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidTarget, fmt.Sprintf("invalid target type: %s", jobTarget.Name))
 			return nil
 		}
+
+		// this is a programming error
+		if targetResult == nil {
+			panic("target results object not created by the target handling code")
+		}
+		osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, targetResult)
+	}
+
+	targetErrors := osbuildJobResult.TargetErrors()
+	if len(targetErrors) != 0 {
+		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorTargetError, "at least one target failed", targetErrors)
+	} else {
+		osbuildJobResult.Success = true
+		osbuildJobResult.UploadStatus = "success"
 	}
 
 	return nil
diff --git a/internal/cloud/gcp/compute.go b/internal/cloud/gcp/compute.go
index 77aeb0ea8..703090376 100644
--- a/internal/cloud/gcp/compute.go
+++ b/internal/cloud/gcp/compute.go
@@ -7,7 +7,6 @@ import (
 
 	compute "cloud.google.com/go/compute/apiv1"
 	"github.com/osbuild/osbuild-composer/internal/common"
-	"google.golang.org/api/iterator"
 	"google.golang.org/api/option"
 	computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
 )
@@ -267,141 +266,3 @@ func (g *GCP) ComputeExecuteFunctionForImages(ctx context.Context, f func(*compu
 	imagesIterator := imagesClient.List(ctx, req)
 	return f(imagesIterator)
 }
-
-// ComputeInstanceDelete deletes a Compute Engine instance with the given name and
-// running in the given zone. If the instance existed and was successfully deleted,
-// no error is returned.
-//
-// Uses:
-//	- Compute Engine API
-func (g *GCP) ComputeInstanceDelete(ctx context.Context, zone, instance string) error {
-	instancesClient, err := compute.NewInstancesRESTClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return fmt.Errorf("failed to get Compute Engine Instances client: %v", err)
-	}
-	defer instancesClient.Close()
-
-	req := &computepb.DeleteInstanceRequest{
-		Project:  g.GetProjectID(),
-		Zone:     zone,
-		Instance: instance,
-	}
-	_, err = instancesClient.Delete(ctx, req)
-
-	return err
-}
-
-// ComputeInstanceGet fetches a Compute Engine instance information. If fetching the information
-// was successful, it is returned to the caller, otherwise <nil> is returned with a proper error.
-//
-// Uses:
-//	- Compute Engine API
-func (g *GCP) ComputeInstanceGet(ctx context.Context, zone, instance string) (*computepb.Instance, error) {
-	instancesClient, err := compute.NewInstancesRESTClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return nil, fmt.Errorf("failed to get Compute Engine Instances client: %v", err)
-	}
-	defer instancesClient.Close()
-
-	req := &computepb.GetInstanceRequest{
-		Project:  g.GetProjectID(),
-		Instance: instance,
-		Zone:     zone,
-	}
-	resp, err := instancesClient.Get(ctx, req)
-
-	return resp, err
-}
-
-// ComputeDiskDelete deletes a Compute Engine disk with the given name and
-// running in the given zone. If the disk existed and was successfully deleted,
-// no error is returned.
-//
-// Uses:
-//	- Compute Engine API
-func (g *GCP) ComputeDiskDelete(ctx context.Context, zone, disk string) error {
-	disksClient, err := compute.NewDisksRESTClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return fmt.Errorf("failed to get Compute Engine Disks client: %v", err)
-	}
-	defer disksClient.Close()
-
-	req := &computepb.DeleteDiskRequest{
-		Project: g.GetProjectID(),
-		Disk:    disk,
-		Zone:    zone,
-	}
-	_, err = disksClient.Delete(ctx, req)
-
-	return err
-}
-
-// ComputeUpZonesInRegion returns list of zones within the given GCE Region, which are "UP".
-//
-// Uses:
-//  - Compute Engine API
-func (g *GCP) ComputeUpZonesInRegion(ctx context.Context, region *computepb.Region) ([]*computepb.Zone, error) {
-	var zones []*computepb.Zone
-
-	zonesClient, err := compute.NewZonesRESTClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return nil, fmt.Errorf("failed to get Compute Engine Zones client: %v", err)
-	}
-	defer zonesClient.Close()
-
-	for _, zoneURL := range region.Zones {
-		// zone URL example - "https://www.googleapis.com/compute/v1/projects/<PROJECT_ID>/zones/us-central1-a"
-		zoneNameSs := strings.Split(zoneURL, "/")
-		zoneName := zoneNameSs[len(zoneNameSs)-1]
-
-		getZoneReq := &computepb.GetZoneRequest{
-			Project: g.GetProjectID(),
-			Zone:    zoneName,
-		}
-		zone, err := zonesClient.Get(ctx, getZoneReq)
-		if err != nil {
-			return nil, fmt.Errorf("failed to get information about Compute Engine zone '%s': %v", zoneName, err)
-		}
-
-		// Make sure to return only Zones, which can be used
-		if zone.GetStatus() == computepb.Zone_UP.String() {
-			zones = append(zones, zone)
-		}
-	}
-
-	return zones, nil
-}
-
-// ComputeRegionsList returns list of GCE regions based on the provided filter.
-//
-// Uses:
-//  - Compute Engine API
-func (g *GCP) ComputeRegionsList(ctx context.Context, filter string) ([]*computepb.Region, error) {
-	var regions []*computepb.Region
-
-	regionsClient, err := compute.NewRegionsRESTClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return nil, fmt.Errorf("failed to get GCE regions client: %v", err)
-	}
-	defer regionsClient.Close()
-
-	listRegionsReq := &computepb.ListRegionsRequest{
-		Project: g.GetProjectID(),
-		Filter:  common.StringToPtr(filter),
-	}
-	regionsIter := regionsClient.List(ctx, listRegionsReq)
-
-	for {
-		resp, err := regionsIter.Next()
-		if err == iterator.Done {
-			break
-		}
-		if err != nil {
-			return nil, fmt.Errorf("error while iterating over GCE regions: %v", err)
-		}
-
-		regions = append(regions, resp)
-	}
-
-	return regions, nil
-}
diff --git a/internal/cloud/gcp/gcp.go b/internal/cloud/gcp/gcp.go
index 046f6dc92..2c7abc400 100644
--- a/internal/cloud/gcp/gcp.go
+++ b/internal/cloud/gcp/gcp.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 	"io/ioutil"
-	"os"
 
 	cloudbuild "cloud.google.com/go/cloudbuild/apiv1"
 	compute "cloud.google.com/go/compute/apiv1"
@@ -65,29 +64,6 @@ func NewFromFile(path string) (*GCP, error) {
 	return New(gcpCredentials)
 }
 
-// GetCredentialsFromEnv reads the service account credentials JSON file from
-// the path pointed to by the environment variable name stored in
-// 'GCPCredentialsEnvName'. If the content of the JSON file was read successfully,
-// its content is returned as []byte, otherwise nil is returned with proper error.
-func GetCredentialsFromEnv() ([]byte, error) {
-	credsPath, exists := os.LookupEnv(GCPCredentialsEnvName)
-
-	if !exists {
-		return nil, fmt.Errorf("'%s' env variable is not set", GCPCredentialsEnvName)
-	}
-	if credsPath == "" {
-		return nil, fmt.Errorf("'%s' env variable is empty", GCPCredentialsEnvName)
-	}
-
-	var err error
-	credentials, err := ioutil.ReadFile(credsPath)
-	if err != nil {
-		return nil, fmt.Errorf("Error while reading credentials file: %s", err)
-	}
-
-	return credentials, nil
-}
-
 // GetProjectID returns a string with the Project ID of the project, used for
 // all GCP operations.
 func (g *GCP) GetProjectID() string {
diff --git a/internal/cloud/gcp/storage.go b/internal/cloud/gcp/storage.go
index 6e933ae59..97dd6cb6a 100644
--- a/internal/cloud/gcp/storage.go
+++ b/internal/cloud/gcp/storage.go
@@ -10,7 +10,6 @@ import (
 	"os"
 
 	"cloud.google.com/go/storage"
-	"google.golang.org/api/iterator"
 	"google.golang.org/api/option"
 )
 
@@ -98,53 +97,3 @@ func (g *GCP) StorageObjectDelete(ctx context.Context, bucket, object string) er
 
 	return nil
 }
-
-// StorageListObjectsByMetadata searches specified Storage bucket for objects matching the provided
-// metadata. The provided metadata is used for filtering the bucket's content. Therefore if the provided
-// metadata is nil, then all objects present in the bucket will be returned.
-//
-// Matched objects are returned as a list of ObjectAttrs.
-//
-// Uses:
-//	- Storage API
-func (g *GCP) StorageListObjectsByMetadata(ctx context.Context, bucket string, metadata map[string]string) ([]*storage.ObjectAttrs, error) {
-	var matchedObjectAttr []*storage.ObjectAttrs
-
-	storageClient, err := storage.NewClient(ctx, option.WithCredentials(g.creds))
-	if err != nil {
-		return nil, fmt.Errorf("failed to get Storage client: %v", err)
-	}
-	defer storageClient.Close()
-
-	objects := storageClient.Bucket(bucket).Objects(ctx, nil)
-	for {
-		obj, err := objects.Next()
-		if err == iterator.Done {
-			break
-		}
-		if err != nil {
-			return nil, fmt.Errorf("failure while iterating over bucket objects: %v", err)
-		}
-
-		// check if the object's Metadata match the provided values
-		metadataMatch := true
-		for key, value := range metadata {
-			objMetadataValue, ok := obj.Metadata[key]
-			if !ok {
-				metadataMatch = false
-				break
-			}
-			if objMetadataValue != value {
-				metadataMatch = false
-				break
-			}
-		}
-
-		if metadataMatch {
-			matchedObjectAttr = append(matchedObjectAttr, obj)
-		}
-
-	}
-
-	return matchedObjectAttr, nil
-}
diff --git a/internal/cloudapi/v2/handler.go b/internal/cloudapi/v2/handler.go
index 38963dff4..87dfc0bad 100644
--- a/internal/cloudapi/v2/handler.go
+++ b/internal/cloudapi/v2/handler.go
@@ -328,7 +328,6 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				// 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,
 					Key:               key,
 					ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
@@ -338,6 +337,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				} else {
 					t.ImageName = key
 				}
+				t.OsbuildArtifact.ExportFilename = imageType.Filename()
 
 				irTarget = t
 			case ImageTypesGuestImage:
@@ -363,11 +363,11 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 
 				key := fmt.Sprintf("composer-api-%s", uuid.New().String())
 				t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
-					Filename: imageType.Filename(),
-					Region:   awsS3UploadOptions.Region,
-					Key:      key,
+					Region: awsS3UploadOptions.Region,
+					Key:    key,
 				})
 				t.ImageName = key
+				t.OsbuildArtifact.ExportFilename = imageType.Filename()
 
 				irTarget = t
 			case ImageTypesGcp:
@@ -388,10 +388,9 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 
 				imageName := fmt.Sprintf("composer-api-%s", uuid.New().String())
 				t := target.NewGCPTarget(&target.GCPTargetOptions{
-					Filename: imageType.Filename(),
-					Region:   gcpUploadOptions.Region,
-					Os:       imageType.Arch().Distro().Name(), // not exposed in cloudapi
-					Bucket:   gcpUploadOptions.Bucket,
+					Region: gcpUploadOptions.Region,
+					Os:     imageType.Arch().Distro().Name(), // not exposed in cloudapi
+					Bucket: gcpUploadOptions.Bucket,
 					// the uploaded object must have a valid extension
 					Object:            fmt.Sprintf("%s.tar.gz", imageName),
 					ShareWithAccounts: share,
@@ -402,6 +401,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 				} else {
 					t.ImageName = imageName
 				}
+				t.OsbuildArtifact.ExportFilename = imageType.Filename()
 
 				irTarget = t
 			case ImageTypesAzure:
@@ -417,7 +417,6 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 					return HTTPError(ErrorJSONUnMarshallingError)
 				}
 				t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
-					Filename:       imageType.Filename(),
 					TenantID:       azureUploadOptions.TenantId,
 					Location:       azureUploadOptions.Location,
 					SubscriptionID: azureUploadOptions.SubscriptionId,
@@ -430,11 +429,14 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 					// if ImageName wasn't given, generate a random one
 					t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
 				}
+				t.OsbuildArtifact.ExportFilename = imageType.Filename()
 
 				irTarget = t
 			default:
 				return HTTPError(ErrorUnsupportedImageType)
 			}
+
+			irTarget.OsbuildArtifact.ExportName = imageType.Exports()[0]
 		}
 
 		irs = append(irs, imageRequest{
@@ -549,27 +551,27 @@ func (h *apiHandlers) getComposeStatusImpl(ctx echo.Context, id string) error {
 			var uploadOptions interface{}
 
 			switch tr.Name {
-			case "org.osbuild.aws":
+			case target.TargetNameAWS:
 				uploadType = UploadTypesAws
 				awsOptions := tr.Options.(*target.AWSTargetResultOptions)
 				uploadOptions = AWSEC2UploadStatus{
 					Ami:    awsOptions.Ami,
 					Region: awsOptions.Region,
 				}
-			case "org.osbuild.aws.s3":
+			case target.TargetNameAWSS3:
 				uploadType = UploadTypesAwsS3
 				awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
 				uploadOptions = AWSS3UploadStatus{
 					Url: awsOptions.URL,
 				}
-			case "org.osbuild.gcp":
+			case target.TargetNameGCP:
 				uploadType = UploadTypesGcp
 				gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
 				uploadOptions = GCPUploadStatus{
 					ImageName: gcpOptions.ImageName,
 					ProjectId: gcpOptions.ProjectID,
 				}
-			case "org.osbuild.azure.image":
+			case target.TargetNameAzureImage:
 				uploadType = UploadTypesAzure
 				gcpOptions := tr.Options.(*target.AzureImageTargetResultOptions)
 				uploadOptions = AzureUploadStatus{
diff --git a/internal/cloudapi/v2/server.go b/internal/cloudapi/v2/server.go
index 40ca19ed6..7ba674822 100644
--- a/internal/cloudapi/v2/server.go
+++ b/internal/cloudapi/v2/server.go
@@ -119,7 +119,6 @@ func (s *Server) enqueueCompose(distribution distro.Distro, bp blueprint.Bluepri
 
 	id, err = s.workers.EnqueueOSBuildAsDependency(ir.arch.Name(), &worker.OSBuildJob{
 		Targets: []*target.Target{ir.target},
-		Exports: ir.imageType.Exports(),
 		PipelineNames: &worker.PipelineNames{
 			Build:   ir.imageType.BuildPipelines(),
 			Payload: ir.imageType.PayloadPipelines(),
@@ -181,12 +180,12 @@ func (s *Server) enqueueKojiCompose(taskID uint64, server, name, version, releas
 		kojiTarget := target.NewKojiTarget(&target.KojiTargetOptions{
 			Server:          server,
 			UploadDirectory: kojiDirectory,
-			Filename:        ir.imageType.Filename(),
 		})
+		kojiTarget.OsbuildArtifact.ExportFilename = ir.imageType.Filename()
+		kojiTarget.OsbuildArtifact.ExportName = ir.imageType.Exports()[0]
 		kojiTarget.ImageName = kojiFilename
 
 		buildID, err := s.workers.EnqueueOSBuildAsDependency(ir.arch.Name(), &worker.OSBuildJob{
-			Exports: ir.imageType.Exports(),
 			PipelineNames: &worker.PipelineNames{
 				Build:   ir.imageType.BuildPipelines(),
 				Payload: ir.imageType.PayloadPipelines(),
diff --git a/internal/store/compose.go b/internal/store/compose.go
index c685949be..76d00fb8d 100644
--- a/internal/store/compose.go
+++ b/internal/store/compose.go
@@ -58,17 +58,6 @@ func (ib *ImageBuild) DeepCopy() ImageBuild {
 	}
 }
 
-func (ib *ImageBuild) GetLocalTargetOptions() *target.LocalTargetOptions {
-	for _, t := range ib.Targets {
-		switch options := t.Options.(type) {
-		case *target.LocalTargetOptions:
-			return options
-		}
-	}
-
-	return nil
-}
-
 // A Compose represent the task of building a set of images from a single blueprint.
 // It contains all the information necessary to generate the inputs for the job, as
 // well as the job's state.
diff --git a/internal/store/fixtures.go b/internal/store/fixtures.go
index eb2bd7be6..72da7be3e 100644
--- a/internal/store/fixtures.go
+++ b/internal/store/fixtures.go
@@ -26,18 +26,9 @@ func FixtureBase() *Store {
 
 	var date = time.Date(2019, 11, 27, 13, 19, 0, 0, time.FixedZone("UTC+1", 60*60))
 
-	var localTarget = &target.Target{
-		Uuid:      uuid.MustParse("20000000-0000-0000-0000-000000000000"),
-		Name:      "org.osbuild.local",
-		ImageName: "localimage",
-		Created:   date,
-		Status:    common.IBWaiting,
-		Options:   &target.LocalTargetOptions{},
-	}
-
 	var awsTarget = &target.Target{
 		Uuid:      uuid.MustParse("10000000-0000-0000-0000-000000000000"),
-		Name:      "org.osbuild.aws",
+		Name:      target.TargetNameAWS,
 		ImageName: "awsimage",
 		Created:   date,
 		Status:    common.IBWaiting,
@@ -88,7 +79,7 @@ func FixtureBase() *Store {
 				QueueStatus: common.IBWaiting,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{awsTarget},
 				JobCreated:  date,
 			},
 			Packages: []rpmmd.PackageSpec{},
@@ -99,7 +90,7 @@ func FixtureBase() *Store {
 				QueueStatus: common.IBRunning,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget},
+				Targets:     []*target.Target{},
 				JobCreated:  date,
 				JobStarted:  date,
 			},
@@ -111,7 +102,7 @@ func FixtureBase() *Store {
 				QueueStatus: common.IBFinished,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{awsTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 				JobFinished: date,
@@ -124,7 +115,7 @@ func FixtureBase() *Store {
 				QueueStatus: common.IBFailed,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{awsTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 				JobFinished: date,
@@ -137,7 +128,7 @@ func FixtureBase() *Store {
 				QueueStatus: common.IBFinished,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{awsTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 				JobFinished: date,
@@ -161,18 +152,18 @@ func FixtureFinished() *Store {
 
 	var date = time.Date(2019, 11, 27, 13, 19, 0, 0, time.FixedZone("UTC+1", 60*60))
 
-	var localTarget = &target.Target{
+	var gcpTarget = &target.Target{
 		Uuid:      uuid.MustParse("20000000-0000-0000-0000-000000000000"),
-		Name:      "org.osbuild.local",
+		Name:      target.TargetNameGCP,
 		ImageName: "localimage",
 		Created:   date,
 		Status:    common.IBWaiting,
-		Options:   &target.LocalTargetOptions{},
+		Options:   &target.GCPTargetOptions{},
 	}
 
 	var awsTarget = &target.Target{
 		Uuid:      uuid.MustParse("10000000-0000-0000-0000-000000000000"),
-		Name:      "org.osbuild.aws",
+		Name:      target.TargetNameAWS,
 		ImageName: "awsimage",
 		Created:   date,
 		Status:    common.IBWaiting,
@@ -223,7 +214,7 @@ func FixtureFinished() *Store {
 				QueueStatus: common.IBFinished,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{gcpTarget, awsTarget},
 				JobCreated:  date,
 			},
 			Packages: []rpmmd.PackageSpec{},
@@ -234,7 +225,7 @@ func FixtureFinished() *Store {
 				QueueStatus: common.IBFinished,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget},
+				Targets:     []*target.Target{gcpTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 			},
@@ -246,7 +237,7 @@ func FixtureFinished() *Store {
 				QueueStatus: common.IBFailed,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget, awsTarget},
+				Targets:     []*target.Target{gcpTarget, awsTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 				JobFinished: date,
@@ -259,7 +250,7 @@ func FixtureFinished() *Store {
 				QueueStatus: common.IBFinished,
 				ImageType:   imgType,
 				Manifest:    manifest,
-				Targets:     []*target.Target{localTarget},
+				Targets:     []*target.Target{gcpTarget},
 				JobCreated:  date,
 				JobStarted:  date,
 			},
diff --git a/internal/target/aws_target.go b/internal/target/aws_target.go
index 756e0f073..4ae93c0fc 100644
--- a/internal/target/aws_target.go
+++ b/internal/target/aws_target.go
@@ -1,7 +1,11 @@
 package target
 
+const (
+	TargetNameAWS   TargetName = "org.osbuild.aws"
+	TargetNameAWSS3 TargetName = "org.osbuild.aws.s3"
+)
+
 type AWSTargetOptions struct {
-	Filename          string   `json:"filename"`
 	Region            string   `json:"region"`
 	AccessKeyID       string   `json:"accessKeyID"`
 	SecretAccessKey   string   `json:"secretAccessKey"`
@@ -14,7 +18,7 @@ type AWSTargetOptions struct {
 func (AWSTargetOptions) isTargetOptions() {}
 
 func NewAWSTarget(options *AWSTargetOptions) *Target {
-	return newTarget("org.osbuild.aws", options)
+	return newTarget(TargetNameAWS, options)
 }
 
 type AWSTargetResultOptions struct {
@@ -25,11 +29,10 @@ type AWSTargetResultOptions struct {
 func (AWSTargetResultOptions) isTargetResultOptions() {}
 
 func NewAWSTargetResult(options *AWSTargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.aws", options)
+	return newTargetResult(TargetNameAWS, options)
 }
 
 type AWSS3TargetOptions struct {
-	Filename            string `json:"filename"`
 	Region              string `json:"region"`
 	AccessKeyID         string `json:"accessKeyID"`
 	SecretAccessKey     string `json:"secretAccessKey"`
@@ -44,7 +47,7 @@ type AWSS3TargetOptions struct {
 func (AWSS3TargetOptions) isTargetOptions() {}
 
 func NewAWSS3Target(options *AWSS3TargetOptions) *Target {
-	return newTarget("org.osbuild.aws.s3", options)
+	return newTarget(TargetNameAWSS3, options)
 }
 
 type AWSS3TargetResultOptions struct {
@@ -54,5 +57,5 @@ type AWSS3TargetResultOptions struct {
 func (AWSS3TargetResultOptions) isTargetResultOptions() {}
 
 func NewAWSS3TargetResult(options *AWSS3TargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.aws.s3", options)
+	return newTargetResult(TargetNameAWSS3, options)
 }
diff --git a/internal/target/azure_image_target.go b/internal/target/azure_image_target.go
index 5116dd8f6..e68e4050e 100644
--- a/internal/target/azure_image_target.go
+++ b/internal/target/azure_image_target.go
@@ -1,7 +1,8 @@
 package target
 
+const TargetNameAzureImage TargetName = "org.osbuild.azure.image"
+
 type AzureImageTargetOptions struct {
-	Filename       string `json:"filename"`
 	TenantID       string `json:"tenant_id"`
 	Location       string `json:"location"`
 	SubscriptionID string `json:"subscription_id"`
@@ -29,7 +30,7 @@ func (AzureImageTargetOptions) isTargetOptions() {}
 // If you need to just upload a PageBlob into Azure Storage, see the
 // org.osbuild.azure target.
 func NewAzureImageTarget(options *AzureImageTargetOptions) *Target {
-	return newTarget("org.osbuild.azure.image", options)
+	return newTarget(TargetNameAzureImage, options)
 }
 
 type AzureImageTargetResultOptions struct {
@@ -39,5 +40,5 @@ type AzureImageTargetResultOptions struct {
 func (AzureImageTargetResultOptions) isTargetResultOptions() {}
 
 func NewAzureImageTargetResult(options *AzureImageTargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.azure.image", options)
+	return newTargetResult(TargetNameAzureImage, options)
 }
diff --git a/internal/target/azure_target.go b/internal/target/azure_target.go
index f60642319..1cb9dd65e 100644
--- a/internal/target/azure_target.go
+++ b/internal/target/azure_target.go
@@ -1,7 +1,8 @@
 package target
 
+const TargetNameAzure TargetName = "org.osbuild.azure"
+
 type AzureTargetOptions struct {
-	Filename         string `json:"filename"`
 	StorageAccount   string `json:"storageAccount"`
 	StorageAccessKey string `json:"storageAccessKey"`
 	Container        string `json:"container"`
@@ -20,5 +21,9 @@ func (AzureTargetOptions) isTargetOptions() {}
 // If you need to upload an Azure Image instead, see the
 // org.osbuild.azure.image target.
 func NewAzureTarget(options *AzureTargetOptions) *Target {
-	return newTarget("org.osbuild.azure", options)
+	return newTarget(TargetNameAzure, options)
+}
+
+func NewAzureTargetResult() *TargetResult {
+	return newTargetResult(TargetNameAzure, nil)
 }
diff --git a/internal/target/container_target.go b/internal/target/container_target.go
index 3bf1f0916..56dc8d8b7 100644
--- a/internal/target/container_target.go
+++ b/internal/target/container_target.go
@@ -1,7 +1,8 @@
 package target
 
+const TargetNameContainer TargetName = "org.osbuild.container"
+
 type ContainerTargetOptions struct {
-	Filename  string `json:"filename"`
 	Reference string `json:"reference"`
 
 	Username string `json:"username,omitempty"`
@@ -13,5 +14,9 @@ type ContainerTargetOptions struct {
 func (ContainerTargetOptions) isTargetOptions() {}
 
 func NewContainerTarget(options *ContainerTargetOptions) *Target {
-	return newTarget("org.osbuild.container", options)
+	return newTarget(TargetNameContainer, options)
+}
+
+func NewContainerTargetResult() *TargetResult {
+	return newTargetResult(TargetNameContainer, nil)
 }
diff --git a/internal/target/gcp_target.go b/internal/target/gcp_target.go
index ac0101efa..008591537 100644
--- a/internal/target/gcp_target.go
+++ b/internal/target/gcp_target.go
@@ -1,7 +1,8 @@
 package target
 
+const TargetNameGCP TargetName = "org.osbuild.gcp"
+
 type GCPTargetOptions struct {
-	Filename          string   `json:"filename"`
 	Region            string   `json:"region"`
 	Os                string   `json:"os"` // not exposed in cloudapi for now
 	Bucket            string   `json:"bucket"`
@@ -17,7 +18,7 @@ type GCPTargetOptions struct {
 func (GCPTargetOptions) isTargetOptions() {}
 
 func NewGCPTarget(options *GCPTargetOptions) *Target {
-	return newTarget("org.osbuild.gcp", options)
+	return newTarget(TargetNameGCP, options)
 }
 
 type GCPTargetResultOptions struct {
@@ -28,5 +29,5 @@ type GCPTargetResultOptions struct {
 func (GCPTargetResultOptions) isTargetResultOptions() {}
 
 func NewGCPTargetResult(options *GCPTargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.gcp", options)
+	return newTargetResult(TargetNameGCP, options)
 }
diff --git a/internal/target/koji_target.go b/internal/target/koji_target.go
index f4e0586b1..72e58de4d 100644
--- a/internal/target/koji_target.go
+++ b/internal/target/koji_target.go
@@ -1,8 +1,8 @@
 package target
 
+const TargetNameKoji TargetName = "org.osbuild.koji"
+
 type KojiTargetOptions struct {
-	// Filename of the image as produced by osbuild for a given export
-	Filename        string `json:"filename"`
 	UploadDirectory string `json:"upload_directory"`
 	Server          string `json:"server"`
 }
@@ -10,7 +10,7 @@ type KojiTargetOptions struct {
 func (KojiTargetOptions) isTargetOptions() {}
 
 func NewKojiTarget(options *KojiTargetOptions) *Target {
-	return newTarget("org.osbuild.koji", options)
+	return newTarget(TargetNameKoji, options)
 }
 
 type KojiTargetResultOptions struct {
@@ -21,5 +21,5 @@ type KojiTargetResultOptions struct {
 func (KojiTargetResultOptions) isTargetResultOptions() {}
 
 func NewKojiTargetResult(options *KojiTargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.koji", options)
+	return newTargetResult(TargetNameKoji, options)
 }
diff --git a/internal/target/local_target.go b/internal/target/local_target.go
index 49083a84f..157cde15d 100644
--- a/internal/target/local_target.go
+++ b/internal/target/local_target.go
@@ -2,6 +2,13 @@ package target
 
 import "github.com/google/uuid"
 
+// Deprecated: TargetNameLocal should not be used by any new code.
+const TargetNameLocal TargetName = "org.osbuild.local"
+
+// Deprecated: LocalTargetOptions should not be used by any new code.
+// The data structure is kept for backward compatibility and to ensure
+// that old osbuild-composer instances which were upgraded will be able
+// to read the target details from the local store.
 type LocalTargetOptions struct {
 	ComposeId       uuid.UUID `json:"compose_id"`
 	ImageBuildId    int       `json:"image_build_id"`
@@ -11,6 +18,7 @@ type LocalTargetOptions struct {
 
 func (LocalTargetOptions) isTargetOptions() {}
 
+// Deprecated: NewLocalTarget should not be used by any new code.
 func NewLocalTarget(options *LocalTargetOptions) *Target {
-	return newTarget("org.osbuild.local", options)
+	return newTarget(TargetNameLocal, options)
 }
diff --git a/internal/target/oci_target.go b/internal/target/oci_target.go
index 399c29669..ffe4a9bd7 100644
--- a/internal/target/oci_target.go
+++ b/internal/target/oci_target.go
@@ -1,10 +1,11 @@
 package target
 
+const TargetNameOCI TargetName = "org.osbuild.oci"
+
 type OCITargetOptions struct {
 	User        string `json:"user"`
 	Tenancy     string `json:"tenancy"`
 	Region      string `json:"region"`
-	FileName    string `json:"filename"`
 	Fingerprint string `json:"fingerprint"`
 	PrivateKey  string `json:"private_key"`
 	Bucket      string `json:"bucket"`
@@ -15,7 +16,7 @@ type OCITargetOptions struct {
 func (OCITargetOptions) isTargetOptions() {}
 
 func NewOCITarget(options *OCITargetOptions) *Target {
-	return newTarget("org.osbuild.oci", options)
+	return newTarget(TargetNameOCI, options)
 }
 
 type OCITargetResultOptions struct {
@@ -26,5 +27,5 @@ type OCITargetResultOptions struct {
 func (OCITargetResultOptions) isTargetResultOptions() {}
 
 func NewOCITargetResult(options *OCITargetResultOptions) *TargetResult {
-	return newTargetResult("org.osbuild.oci", options)
+	return newTargetResult(TargetNameOCI, options)
 }
diff --git a/internal/target/target.go b/internal/target/target.go
index 417c8cb09..65c814f20 100644
--- a/internal/target/target.go
+++ b/internal/target/target.go
@@ -2,23 +2,39 @@ package target
 
 import (
 	"encoding/json"
-	"errors"
+	"fmt"
 	"time"
 
 	"github.com/google/uuid"
 	"github.com/osbuild/osbuild-composer/internal/common"
 )
 
+type TargetName string
+
+// OsbuildArtifact represents a configuration to produce osbuild artifact
+// specific to a target.
+type OsbuildArtifact struct {
+	// Filename of the image as produced by osbuild for a given export
+	ExportFilename string `json:"export_filename"`
+	// Name of the osbuild pipeline, which should be exported for this target
+	ExportName string `json:"export_name"`
+}
+
 type Target struct {
-	Uuid      uuid.UUID              `json:"uuid"`
-	ImageName string                 `json:"image_name"` // Desired name of the image in the target environment
-	Name      string                 `json:"name"`       // Name of the specific target type
-	Created   time.Time              `json:"created"`
-	Status    common.ImageBuildState `json:"status"`
-	Options   TargetOptions          `json:"options"` // Target type specific options
+	Uuid uuid.UUID `json:"uuid"`
+	// Desired name of the image in the target environment
+	ImageName string `json:"image_name"`
+	// Name of the specific target type
+	Name    TargetName             `json:"name"`
+	Created time.Time              `json:"created"`
+	Status  common.ImageBuildState `json:"status"`
+	// Target type specific options
+	Options TargetOptions `json:"options"`
+	// Configuration to produce osbuild artifact specific to this target
+	OsbuildArtifact OsbuildArtifact `json:"osbuild_artifact"`
 }
 
-func newTarget(name string, options TargetOptions) *Target {
+func newTarget(name TargetName, options TargetOptions) *Target {
 	return &Target{
 		Uuid:    uuid.New(),
 		Name:    name,
@@ -35,10 +51,12 @@ type TargetOptions interface {
 type rawTarget struct {
 	Uuid      uuid.UUID              `json:"uuid"`
 	ImageName string                 `json:"image_name"`
-	Name      string                 `json:"name"`
+	Name      TargetName             `json:"name"`
 	Created   time.Time              `json:"created"`
 	Status    common.ImageBuildState `json:"status"`
 	Options   json.RawMessage        `json:"options"`
+	// Configuration to produce osbuild artifact specific to this target
+	OsbuildArtifact OsbuildArtifact `json:"osbuild_artifact"`
 }
 
 func (target *Target) UnmarshalJSON(data []byte) error {
@@ -47,48 +65,224 @@ func (target *Target) UnmarshalJSON(data []byte) error {
 	if err != nil {
 		return err
 	}
-	options, err := UnmarshalTargetOptions(rawTarget.Name, rawTarget.Options)
+
+	var options TargetOptions
+	switch rawTarget.Name {
+	case TargetNameAzure:
+		options = new(AzureTargetOptions)
+	case TargetNameAWS:
+		options = new(AWSTargetOptions)
+	case TargetNameAWSS3:
+		options = new(AWSS3TargetOptions)
+	case TargetNameGCP:
+		options = new(GCPTargetOptions)
+	case TargetNameAzureImage:
+		options = new(AzureImageTargetOptions)
+	// Kept for backward compatibility
+	case TargetNameLocal:
+		options = new(LocalTargetOptions)
+	case TargetNameKoji:
+		options = new(KojiTargetOptions)
+	case TargetNameVMWare:
+		options = new(VMWareTargetOptions)
+	case TargetNameOCI:
+		options = new(OCITargetOptions)
+	case TargetNameContainer:
+		options = new(ContainerTargetOptions)
+	case TargetNameWorkerServer:
+		options = new(WorkerServerTargetOptions)
+	default:
+		return fmt.Errorf("unexpected target name: %s", rawTarget.Name)
+	}
+
+	err = json.Unmarshal(rawTarget.Options, options)
 	if err != nil {
 		return err
 	}
 
 	target.Uuid = rawTarget.Uuid
 	target.ImageName = rawTarget.ImageName
+	target.OsbuildArtifact = rawTarget.OsbuildArtifact
 	target.Name = rawTarget.Name
 	target.Created = rawTarget.Created
 	target.Status = rawTarget.Status
 	target.Options = options
 
+	type compatOptionsType struct {
+		// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+		Filename string `json:"filename"`
+	}
+
+	var compat compatOptionsType
+	err = json.Unmarshal(rawTarget.Options, &compat)
+	if err != nil {
+		return err
+	}
+
+	// Kept for backward compatibility
+	// If the `ExportTarget` is not set in the `Target`, the request is most probably
+	// coming from an old composer. Copy the value from the target options.
+	if target.OsbuildArtifact.ExportFilename == "" {
+		target.OsbuildArtifact.ExportFilename = compat.Filename
+	}
+
 	return nil
 }
 
-func UnmarshalTargetOptions(targetName string, rawOptions json.RawMessage) (TargetOptions, error) {
-	var options TargetOptions
-	switch targetName {
-	case "org.osbuild.azure":
-		options = new(AzureTargetOptions)
-	case "org.osbuild.aws":
-		options = new(AWSTargetOptions)
-	case "org.osbuild.aws.s3":
-		options = new(AWSS3TargetOptions)
-	case "org.osbuild.gcp":
-		options = new(GCPTargetOptions)
-	case "org.osbuild.azure.image":
-		options = new(AzureImageTargetOptions)
-	case "org.osbuild.local":
-		options = new(LocalTargetOptions)
-	case "org.osbuild.koji":
-		options = new(KojiTargetOptions)
-	case "org.osbuild.vmware":
-		options = new(VMWareTargetOptions)
-	case "org.osbuild.oci":
-		options = new(OCITargetOptions)
-	case "org.osbuild.container":
-		options = new(ContainerTargetOptions)
-	default:
-		return nil, errors.New("unexpected target name")
+func (target Target) MarshalJSON() ([]byte, error) {
+	// We can't use composition of the `TargetOptions` interface into a compatibility
+	// structure, because the value assigned to the embedded interface type member
+	// would get marshaled under the name of the type.
+	var rawOptions []byte
+	var err error
+	if target.Options != nil {
+		switch t := target.Options.(type) {
+		case *AWSTargetOptions:
+			type compatOptionsType struct {
+				*AWSTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				AWSTargetOptions: t,
+				Filename:         target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *AWSS3TargetOptions:
+			type compatOptionsType struct {
+				*AWSS3TargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				AWSS3TargetOptions: t,
+				Filename:           target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *AzureTargetOptions:
+			type compatOptionsType struct {
+				*AzureTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				AzureTargetOptions: t,
+				Filename:           target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *GCPTargetOptions:
+			type compatOptionsType struct {
+				*GCPTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				GCPTargetOptions: t,
+				Filename:         target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *AzureImageTargetOptions:
+			type compatOptionsType struct {
+				*AzureImageTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				AzureImageTargetOptions: t,
+				Filename:                target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		// Kept for backward compatibility
+		case *LocalTargetOptions:
+			type compatOptionsType struct {
+				*LocalTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				LocalTargetOptions: t,
+				Filename:           target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *KojiTargetOptions:
+			type compatOptionsType struct {
+				*KojiTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				KojiTargetOptions: t,
+				Filename:          target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *VMWareTargetOptions:
+			type compatOptionsType struct {
+				*VMWareTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				VMWareTargetOptions: t,
+				Filename:            target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *OCITargetOptions:
+			type compatOptionsType struct {
+				*OCITargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				OCITargetOptions: t,
+				Filename:         target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *ContainerTargetOptions:
+			type compatOptionsType struct {
+				*ContainerTargetOptions
+				// Deprecated: `Filename` is now set in the target itself as `ExportFilename`, not in its options.
+				Filename string `json:"filename"`
+			}
+			compat := compatOptionsType{
+				ContainerTargetOptions: t,
+				Filename:               target.OsbuildArtifact.ExportFilename,
+			}
+			rawOptions, err = json.Marshal(compat)
+
+		case *WorkerServerTargetOptions:
+			// WorkerServer target does not handle the backward compatibility
+			// for the Filename in target options, because it was added after
+			// the incompatible change.
+			rawOptions, err = json.Marshal(target.Options)
+
+		default:
+			return nil, fmt.Errorf("unexpected target options type: %t", t)
+		}
+
+		// check error from marshaling
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	alias := rawTarget{
+		Uuid:            target.Uuid,
+		ImageName:       target.ImageName,
+		OsbuildArtifact: target.OsbuildArtifact,
+		Name:            target.Name,
+		Created:         target.Created,
+		Status:          target.Status,
+		Options:         json.RawMessage(rawOptions),
 	}
-	err := json.Unmarshal(rawOptions, options)
 
-	return options, err
+	return json.Marshal(alias)
 }
diff --git a/internal/target/targetresult.go b/internal/target/targetresult.go
index 971f9c443..60efdaf62 100644
--- a/internal/target/targetresult.go
+++ b/internal/target/targetresult.go
@@ -3,14 +3,17 @@ package target
 import (
 	"encoding/json"
 	"fmt"
+
+	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
 )
 
 type TargetResult struct {
-	Name    string              `json:"name"`
-	Options TargetResultOptions `json:"options"`
+	Name        TargetName          `json:"name"`
+	Options     TargetResultOptions `json:"options,omitempty"`
+	TargetError *clienterrors.Error `json:"target_error,omitempty"`
 }
 
-func newTargetResult(name string, options TargetResultOptions) *TargetResult {
+func newTargetResult(name TargetName, options TargetResultOptions) *TargetResult {
 	return &TargetResult{
 		Name:    name,
 		Options: options,
@@ -22,8 +25,9 @@ type TargetResultOptions interface {
 }
 
 type rawTargetResult struct {
-	Name    string          `json:"name"`
-	Options json.RawMessage `json:"options"`
+	Name        TargetName          `json:"name"`
+	Options     json.RawMessage     `json:"options,omitempty"`
+	TargetError *clienterrors.Error `json:"target_error,omitempty"`
 }
 
 func (targetResult *TargetResult) UnmarshalJSON(data []byte) error {
@@ -32,30 +36,36 @@ func (targetResult *TargetResult) UnmarshalJSON(data []byte) error {
 	if err != nil {
 		return err
 	}
-	options, err := UnmarshalTargetResultOptions(rawTR.Name, rawTR.Options)
-	if err != nil {
-		return err
+	var options TargetResultOptions
+	// No options may be set if there was a target error.
+	// In addition, some targets don't set any options.
+	if len(rawTR.Options) > 0 {
+		options, err = UnmarshalTargetResultOptions(rawTR.Name, rawTR.Options)
+		if err != nil {
+			return err
+		}
 	}
 
 	targetResult.Name = rawTR.Name
 	targetResult.Options = options
+	targetResult.TargetError = rawTR.TargetError
 	return nil
 }
 
-func UnmarshalTargetResultOptions(trName string, rawOptions json.RawMessage) (TargetResultOptions, error) {
+func UnmarshalTargetResultOptions(trName TargetName, rawOptions json.RawMessage) (TargetResultOptions, error) {
 	var options TargetResultOptions
 	switch trName {
-	case "org.osbuild.aws":
+	case TargetNameAWS:
 		options = new(AWSTargetResultOptions)
-	case "org.osbuild.aws.s3":
+	case TargetNameAWSS3:
 		options = new(AWSS3TargetResultOptions)
-	case "org.osbuild.gcp":
+	case TargetNameGCP:
 		options = new(GCPTargetResultOptions)
-	case "org.osbuild.azure.image":
+	case TargetNameAzureImage:
 		options = new(AzureImageTargetResultOptions)
-	case "org.osbuild.koji":
+	case TargetNameKoji:
 		options = new(KojiTargetResultOptions)
-	case "org.osbuild.oci":
+	case TargetNameOCI:
 		options = new(OCITargetResultOptions)
 	default:
 		return nil, fmt.Errorf("unexpected target result name: %s", trName)
diff --git a/internal/target/vmware_target.go b/internal/target/vmware_target.go
index 7fcba6710..72f8be877 100644
--- a/internal/target/vmware_target.go
+++ b/internal/target/vmware_target.go
@@ -1,7 +1,8 @@
 package target
 
+const TargetNameVMWare TargetName = "org.osbuild.vmware"
+
 type VMWareTargetOptions struct {
-	Filename   string `json:"filename"`
 	Host       string `json:"host"`
 	Username   string `json:"username"`
 	Password   string `json:"password"`
@@ -13,5 +14,9 @@ type VMWareTargetOptions struct {
 func (VMWareTargetOptions) isTargetOptions() {}
 
 func NewVMWareTarget(options *VMWareTargetOptions) *Target {
-	return newTarget("org.osbuild.vmware", options)
+	return newTarget(TargetNameVMWare, options)
+}
+
+func NewVMWareTargetResult() *TargetResult {
+	return newTargetResult(TargetNameVMWare, nil)
 }
diff --git a/internal/target/worker_server_target.go b/internal/target/worker_server_target.go
new file mode 100644
index 000000000..880aea414
--- /dev/null
+++ b/internal/target/worker_server_target.go
@@ -0,0 +1,15 @@
+package target
+
+const TargetNameWorkerServer TargetName = "org.osbuild.worker.server"
+
+type WorkerServerTargetOptions struct{}
+
+func (WorkerServerTargetOptions) isTargetOptions() {}
+
+func NewWorkerServerTarget() *Target {
+	return newTarget(TargetNameWorkerServer, &WorkerServerTargetOptions{})
+}
+
+func NewWorkerServerTargetResult() *TargetResult {
+	return newTargetResult(TargetNameWorkerServer, nil)
+}
diff --git a/internal/upload/vmware/vmware.go b/internal/upload/vmware/vmware.go
index 826d017d1..73b4a5011 100644
--- a/internal/upload/vmware/vmware.go
+++ b/internal/upload/vmware/vmware.go
@@ -3,9 +3,6 @@ package vmware
 import (
 	"errors"
 	"fmt"
-	"os"
-	"os/exec"
-	"strings"
 
 	"github.com/vmware/govmomi/govc/cli"
 	_ "github.com/vmware/govmomi/govc/importx"
@@ -20,22 +17,6 @@ type Credentials struct {
 	Datastore  string
 }
 
-func OpenAsStreamOptimizedVmdk(imagePath string) (*os.File, error) {
-	newPath := strings.TrimSuffix(imagePath, ".vmdk") + "-stream.vmdk"
-	cmd := exec.Command(
-		"/usr/bin/qemu-img", "convert", "-O", "vmdk", "-o", "subformat=streamOptimized",
-		imagePath, newPath)
-	err := cmd.Run()
-	if err != nil {
-		return nil, err
-	}
-	f, err := os.Open(newPath)
-	if err != nil {
-		return nil, err
-	}
-	return f, err
-}
-
 // UploadImage is a function that uploads a stream optimized vmdk image to vSphere
 // uploaded image will be present in a directory of the same name
 func UploadImage(creds Credentials, imagePath string) error {
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 78cc38ab2..91ee1e921 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -2247,10 +2247,17 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	composeID := uuid.New()
 
 	var targets []*target.Target
+	// Always instruct the worker to upload the artifact back to the server
+	workerServerTarget := target.NewWorkerServerTarget()
+	workerServerTarget.ImageName = imageType.Filename()
+	workerServerTarget.OsbuildArtifact.ExportFilename = imageType.Filename()
+	workerServerTarget.OsbuildArtifact.ExportName = imageType.Exports()[0]
+	targets = append(targets, workerServerTarget)
 	if isRequestVersionAtLeast(params, 1) && cr.Upload != nil {
 		t := uploadRequestToTarget(*cr.Upload, imageType)
 		targets = append(targets, t)
 	}
+
 	// Check for test parameter
 	q, err := url.ParseQuery(request.URL.RawQuery)
 	if err != nil {
@@ -2348,10 +2355,8 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		var jobId uuid.UUID
 
 		jobId, err = api.workers.EnqueueOSBuild(api.archName, &worker.OSBuildJob{
-			Manifest:  manifest,
-			Targets:   targets,
-			ImageName: imageType.Filename(),
-			Exports:   imageType.Exports(),
+			Manifest: manifest,
+			Targets:  targets,
 			PipelineNames: &worker.PipelineNames{
 				Build:   imageType.BuildPipelines(),
 				Payload: imageType.PayloadPipelines(),
diff --git a/internal/weldr/upload.go b/internal/weldr/upload.go
index edba2642a..df7e2a70b 100644
--- a/internal/weldr/upload.go
+++ b/internal/weldr/upload.go
@@ -62,10 +62,9 @@ type azureUploadSettings struct {
 func (azureUploadSettings) isUploadSettings() {}
 
 type gcpUploadSettings struct {
-	Filename string `json:"filename"`
-	Region   string `json:"region"`
-	Bucket   string `json:"bucket"`
-	Object   string `json:"object"`
+	Region string `json:"region"`
+	Bucket string `json:"bucket"`
+	Object string `json:"object"`
 
 	// base64 encoded GCP credentials JSON file
 	Credentials string `json:"credentials,omitempty"`
@@ -208,10 +207,9 @@ func targetsToUploadResponses(targets []*target.Target, state ComposeState) []up
 		case *target.GCPTargetOptions:
 			upload.ProviderName = "gcp"
 			upload.Settings = &gcpUploadSettings{
-				Filename: options.Filename,
-				Region:   options.Region,
-				Bucket:   options.Bucket,
-				Object:   options.Object,
+				Region: options.Region,
+				Bucket: options.Bucket,
+				Object: options.Object,
 				// Credentials are intentionally not included.
 			}
 			uploads = append(uploads, upload)
@@ -245,14 +243,15 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 
 	t.Uuid = uuid.New()
 	t.ImageName = u.ImageName
+	t.OsbuildArtifact.ExportFilename = imageType.Filename()
+	t.OsbuildArtifact.ExportName = imageType.Exports()[0]
 	t.Status = common.IBWaiting
 	t.Created = time.Now()
 
 	switch options := u.Settings.(type) {
 	case *awsUploadSettings:
-		t.Name = "org.osbuild.aws"
+		t.Name = target.TargetNameAWS
 		t.Options = &target.AWSTargetOptions{
-			Filename:        imageType.Filename(),
 			Region:          options.Region,
 			AccessKeyID:     options.AccessKeyID,
 			SecretAccessKey: options.SecretAccessKey,
@@ -261,9 +260,8 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Key:             options.Key,
 		}
 	case *awsS3UploadSettings:
-		t.Name = "org.osbuild.aws.s3"
+		t.Name = target.TargetNameAWSS3
 		t.Options = &target.AWSS3TargetOptions{
-			Filename:            imageType.Filename(),
 			Region:              options.Region,
 			AccessKeyID:         options.AccessKeyID,
 			SecretAccessKey:     options.SecretAccessKey,
@@ -275,15 +273,14 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			SkipSSLVerification: options.SkipSSLVerification,
 		}
 	case *azureUploadSettings:
-		t.Name = "org.osbuild.azure"
+		t.Name = target.TargetNameAzure
 		t.Options = &target.AzureTargetOptions{
-			Filename:         imageType.Filename(),
 			StorageAccount:   options.StorageAccount,
 			StorageAccessKey: options.StorageAccessKey,
 			Container:        options.Container,
 		}
 	case *gcpUploadSettings:
-		t.Name = "org.osbuild.gcp"
+		t.Name = target.TargetNameGCP
 
 		var gcpCredentials []byte
 		var err error
@@ -302,7 +299,6 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 		}
 
 		t.Options = &target.GCPTargetOptions{
-			Filename:    imageType.Filename(),
 			Region:      options.Region,
 			Os:          imageType.Arch().Distro().Name(),
 			Bucket:      options.Bucket,
@@ -310,9 +306,8 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Credentials: gcpCredentials,
 		}
 	case *vmwareUploadSettings:
-		t.Name = "org.osbuild.vmware"
+		t.Name = target.TargetNameVMWare
 		t.Options = &target.VMWareTargetOptions{
-			Filename:   imageType.Filename(),
 			Username:   options.Username,
 			Password:   options.Password,
 			Host:       options.Host,
@@ -321,12 +316,11 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Datastore:  options.Datastore,
 		}
 	case *ociUploadSettings:
-		t.Name = "org.osbuild.oci"
+		t.Name = target.TargetNameOCI
 		t.Options = &target.OCITargetOptions{
 			User:        options.User,
 			Tenancy:     options.Tenancy,
 			Region:      options.Region,
-			FileName:    imageType.Filename(),
 			PrivateKey:  options.PrivateKey,
 			Fingerprint: options.Fingerprint,
 			Bucket:      options.Bucket,
@@ -334,12 +328,11 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Compartment: options.Compartment,
 		}
 	case *containerUploadSettings:
-		t.Name = "org.osbuild.container"
+		t.Name = target.TargetNameContainer
 		t.Options = &target.ContainerTargetOptions{
 			Username: options.Username,
 			Password: options.Password,
 
-			Filename:  imageType.Filename(),
 			TlsVerify: options.TlsVerify,
 		}
 	}
diff --git a/internal/worker/clienterrors/errors.go b/internal/worker/clienterrors/errors.go
index 18c749335..b8587f004 100644
--- a/internal/worker/clienterrors/errors.go
+++ b/internal/worker/clienterrors/errors.go
@@ -28,6 +28,7 @@ const (
 	ErrorDNFRepoError         ClientErrorCode = 25
 	ErrorJobDependency        ClientErrorCode = 26
 	ErrorJobMissingHeartbeat  ClientErrorCode = 27
+	ErrorTargetError          ClientErrorCode = 28
 )
 
 type ClientErrorCode int
@@ -69,6 +70,8 @@ func GetStatusCode(err *Error) StatusCode {
 		return JobStatusUserInputError
 	case ErrorInvalidTarget:
 		return JobStatusUserInputError
+	case ErrorTargetError:
+		return JobStatusUserInputError
 	case ErrorDepsolveDependency:
 		return JobStatusUserInputError
 	case ErrorManifestDependency:
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 05d3cd877..a0fd3d1af 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -20,13 +20,23 @@ type OSBuildJob struct {
 	// Index of the ManifestJobByIDResult instance in the job's dynamic arguments slice
 	ManifestDynArgsIdx *int             `json:"manifest_dyn_args_idx,omitempty"`
 	Targets            []*target.Target `json:"targets,omitempty"`
-	ImageName          string           `json:"image_name,omitempty"`
-
-	// TODO: Delete this after "some" time (kept for backward compatibility)
-	StreamOptimized bool `json:"stream_optimized,omitempty"`
+	PipelineNames      *PipelineNames   `json:"pipeline_names,omitempty"`
+}
 
-	Exports       []string       `json:"export_stages,omitempty"`
-	PipelineNames *PipelineNames `json:"pipeline_names,omitempty"`
+// OsbuildExports returns a slice of osbuild pipeline names, which should be
+// exported as part of running osbuild image build for the job. The pipeline
+// names are gathered from the targets specified in the job.
+func (j OSBuildJob) OsbuildExports() []string {
+	exports := []string{}
+	seenExports := map[string]bool{}
+	for _, target := range j.Targets {
+		exists := seenExports[target.OsbuildArtifact.ExportName]
+		if !exists {
+			seenExports[target.OsbuildArtifact.ExportName] = true
+			exports = append(exports, target.OsbuildArtifact.ExportName)
+		}
+	}
+	return exports
 }
 
 type JobResult struct {
@@ -37,7 +47,6 @@ type OSBuildJobResult struct {
 	Success       bool                   `json:"success"`
 	OSBuildOutput *osbuild.Result        `json:"osbuild_output,omitempty"`
 	TargetResults []*target.TargetResult `json:"target_results,omitempty"`
-	TargetErrors  []string               `json:"target_errors,omitempty"`
 	UploadStatus  string                 `json:"upload_status"`
 	PipelineNames *PipelineNames         `json:"pipeline_names,omitempty"`
 	// Host OS of the worker which handled the job
@@ -47,6 +56,25 @@ type OSBuildJobResult struct {
 	JobResult
 }
 
+// TargetErrors returns a slice of *clienterrors.Error gathered
+// from the job result's target results. If there were no target errors
+// then the returned slice will be empty.
+func (j *OSBuildJobResult) TargetErrors() []*clienterrors.Error {
+	targetErrors := []*clienterrors.Error{}
+
+	for _, targetResult := range j.TargetResults {
+		if targetResult.TargetError != nil {
+			targetError := targetResult.TargetError
+			// Add the target name to the error details, because the error reason
+			// may not contain any information to determine the type of the target
+			// which failed.
+			targetErrors = append(targetErrors, clienterrors.WorkerClientError(targetError.ID, targetError.Reason, targetResult.Name))
+		}
+	}
+
+	return targetErrors
+}
+
 type KojiInitJob struct {
 	Server  string `json:"server"`
 	Name    string `json:"name"`
@@ -226,21 +254,64 @@ type updateJobRequest struct {
 func (j *OSBuildJob) UnmarshalJSON(data []byte) error {
 	// handles unmarshalling old jobs in the queue that don't contain newer fields
 	// adds default/fallback values to missing data
-	type aliastype OSBuildJob
-	var alias aliastype
-	if err := json.Unmarshal(data, &alias); err != nil {
+	type aliasType OSBuildJob
+	type compatType struct {
+		aliasType
+		// Deprecated: Exports should not be used. The export is set in the `Target.OsbuildExport`
+		Exports []string `json:"export_stages,omitempty"`
+	}
+	var compat compatType
+	if err := json.Unmarshal(data, &compat); err != nil {
 		return err
 	}
-	if alias.PipelineNames == nil {
-		alias.PipelineNames = &PipelineNames{
+	if compat.PipelineNames == nil {
+		compat.PipelineNames = &PipelineNames{
 			Build:   distro.BuildPipelinesFallback(),
 			Payload: distro.PayloadPipelinesFallback(),
 		}
 	}
-	*j = OSBuildJob(alias)
+
+	// Exports used to be specified in the job, but there could be always only a single export specified.
+	if len(compat.Exports) != 0 {
+		if len(compat.Exports) > 1 {
+			return fmt.Errorf("osbuild job has more than one exports specified")
+		}
+		export := compat.Exports[0]
+		// add the single export to each target
+		for idx := range compat.Targets {
+			target := compat.Targets[idx]
+			if target.OsbuildArtifact.ExportName == "" {
+				target.OsbuildArtifact.ExportName = export
+			} else if target.OsbuildArtifact.ExportName != export {
+				return fmt.Errorf("osbuild job has different global exports and export in the target specified at the same time")
+			}
+			compat.Targets[idx] = target
+		}
+	}
+
+	*j = OSBuildJob(compat.aliasType)
 	return nil
 }
 
+func (j OSBuildJob) MarshalJSON() ([]byte, error) {
+	type aliasType OSBuildJob
+	type compatType struct {
+		aliasType
+		// Depredated: Exports should not be used. The export is set in the `Target.OsbuildExport`
+		Exports []string `json:"export_stages,omitempty"`
+	}
+	compat := compatType{
+		aliasType: aliasType(j),
+	}
+	compat.Exports = j.OsbuildExports()
+
+	data, err := json.Marshal(&compat)
+	if err != nil {
+		return nil, err
+	}
+	return data, nil
+}
+
 func (j *OSBuildJobResult) UnmarshalJSON(data []byte) error {
 	// handles unmarshalling old jobs in the queue that don't contain newer fields
 	// adds default/fallback values to missing data
diff --git a/internal/worker/server.go b/internal/worker/server.go
index 6c5efb811..b5e060453 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -258,8 +258,8 @@ func (s *Server) OSBuildJobStatus(id uuid.UUID, result *OSBuildJobResult) (*JobS
 			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])
+		} else if len(result.TargetErrors()) > 0 {
+			result.JobError = clienterrors.WorkerClientError(clienterrors.ErrorTargetError, "at least one target failed", result.TargetErrors())
 		}
 	}
 	// For backwards compatibility: OSBuildJobResult didn't use to have a
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index 0e6c5f605..1d1aeb331 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -129,7 +129,6 @@ go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-auth-tes
 go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-koji-tests %{goipath}/cmd/osbuild-koji-tests
 go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-composer-dbjobqueue-tests %{goipath}/cmd/osbuild-composer-dbjobqueue-tests
 go test -c -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-composer-manifest-tests %{goipath}/cmd/osbuild-composer-manifest-tests
-go build -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/cloud-cleaner %{goipath}/cmd/cloud-cleaner
 go build -tags=integration -ldflags="${TEST_LDFLAGS}" -o _bin/osbuild-mock-openid-provider %{goipath}/cmd/osbuild-mock-openid-provider
 
 %endif
@@ -195,7 +194,6 @@ install -m 0755 -vp _bin/osbuild-auth-tests                        %{buildroot}%
 install -m 0755 -vp _bin/osbuild-koji-tests                        %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp _bin/osbuild-composer-dbjobqueue-tests         %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp _bin/osbuild-composer-manifest-tests           %{buildroot}%{_libexecdir}/osbuild-composer-test/
-install -m 0755 -vp _bin/cloud-cleaner                             %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp _bin/osbuild-mock-openid-provider              %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/define-compose-url.sh                    %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/provision.sh                             %{buildroot}%{_libexecdir}/osbuild-composer-test/


Test patch:
diff --git a/cmd/osbuild-image-tests/main_test.go b/cmd/osbuild-image-tests/main_test.go
index e3936e2f7..2c27a39d1 100644
--- a/cmd/osbuild-image-tests/main_test.go
+++ b/cmd/osbuild-image-tests/main_test.go
@@ -32,7 +32,6 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/common"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/test"
-	"github.com/osbuild/osbuild-composer/internal/upload/vmware"
 )
 
 type testcaseStruct struct {
@@ -447,13 +446,6 @@ func testBootUsingVMware(t *testing.T, imagePath string) {
 	}
 	require.NoError(t, err)
 
-	// convert to streamOptimized vmdk
-	imageF, err := vmware.OpenAsStreamOptimizedVmdk(imagePath)
-	require.NoError(t, err)
-	// we don't need the file descriptor to be opened b/c import.vmdk operates
-	// on the file path
-	imageF.Close()
-	imagePath = imageF.Name()
 	require.NotEqual(t, "", imagePath)
 	defer os.Remove(imagePath)
 
diff --git a/internal/cloudapi/v2/v2_koji_test.go b/internal/cloudapi/v2/v2_koji_test.go
index 3092add74..92117c812 100644
--- a/internal/cloudapi/v2/v2_koji_test.go
+++ b/internal/cloudapi/v2/v2_koji_test.go
@@ -477,7 +477,7 @@ func TestKojiCompose(t *testing.T) {
 				require.NoError(t, err)
 				jobTarget := osbuildJob.Targets[0].Options.(*target.KojiTargetOptions)
 				require.Equal(t, "koji.example.com", jobTarget.Server)
-				require.Equal(t, "test.img", jobTarget.Filename)
+				require.Equal(t, "test.img", osbuildJob.Targets[0].OsbuildArtifact.ExportFilename)
 				require.Equal(t, fmt.Sprintf("%s-%s-%s.%s.img", name, version, release, test_distro.TestArch3Name),
 					osbuildJob.Targets[0].ImageName)
 				require.NotEmpty(t, jobTarget.UploadDirectory)
@@ -596,14 +596,14 @@ func TestKojiJobTypeValidation(t *testing.T) {
 	buildJobIDs := make([]uuid.UUID, nImages)
 	filenames := make([]string, nImages)
 	for idx := 0; idx < nImages; idx++ {
-		fname := fmt.Sprintf("image-file-%04d", idx)
+		kojiTarget := target.NewKojiTarget(&target.KojiTargetOptions{
+			Server:          "test-server",
+			UploadDirectory: "koji-server-test-dir",
+		})
+		kojiTarget.OsbuildArtifact.ExportFilename = "test.img"
+		kojiTarget.ImageName = fmt.Sprintf("image-file-%04d", idx)
 		buildJob := worker.OSBuildJob{
-			ImageName: fmt.Sprintf("build-job-%04d", idx),
-			Targets: []*target.Target{target.NewKojiTarget(&target.KojiTargetOptions{
-				Server:          "test-server",
-				UploadDirectory: "koji-server-test-dir",
-				Filename:        fname,
-			})},
+			Targets: []*target.Target{kojiTarget},
 			// Add an empty manifest as a static job argument to make the test pass.
 			// Becasue of a bug in the API, the test was passing even without
 			// any manifest being attached to the job (static or dynamic).
@@ -616,7 +616,7 @@ func TestKojiJobTypeValidation(t *testing.T) {
 
 		buildJobs[idx] = buildJob
 		buildJobIDs[idx] = buildID
-		filenames[idx] = fname
+		filenames[idx] = kojiTarget.OsbuildArtifact.ExportFilename
 	}
 
 	finalizeJob := worker.KojiFinalizeJob{
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index b2bde015b..e03952aea 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -717,65 +717,6 @@ func TestComposeStatusFailure(t *testing.T) {
 	}`, jobId, jobId))
 }
 
-func TestComposeLegacyError(t *testing.T) {
-	srv, wrksrv, _, cancel := newV2Server(t, t.TempDir(), []string{""}, false)
-	defer cancel()
-
-	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
-	{
-		"distribution": "%s",
-		"image_request":{
-			"architecture": "%s",
-			"image_type": "aws",
-			"repositories": [{
-				"baseurl": "somerepo.org",
-				"rhsm": false
-			}],
-			"upload_options": {
-				"region": "eu-central-1"
-			}
-		 }
-	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
-	{
-		"href": "/api/image-builder-composer/v2/compose",
-		"kind": "ComposeId"
-	}`, "id")
-
-	jobId, token, jobType, _, _, err := wrksrv.RequestJob(context.Background(), test_distro.TestArch3Name, []string{worker.JobTypeOSBuild}, []string{""})
-	require.NoError(t, err)
-	require.Equal(t, worker.JobTypeOSBuild, jobType)
-
-	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
-	{
-		"href": "/api/image-builder-composer/v2/composes/%v",
-		"kind": "ComposeStatus",
-		"id": "%v",
-		"image_status": {"status": "building"},
-		"status": "pending"
-	}`, jobId, jobId))
-
-	jobResult, err := json.Marshal(worker.OSBuildJobResult{TargetErrors: []string{"Osbuild failed"}})
-	require.NoError(t, err)
-
-	err = wrksrv.FinishJob(token, jobResult)
-	require.NoError(t, err)
-	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "GET", fmt.Sprintf("/api/image-builder-composer/v2/composes/%v", jobId), ``, http.StatusOK, fmt.Sprintf(`
-	{
-		"href": "/api/image-builder-composer/v2/composes/%v",
-		"kind": "ComposeStatus",
-		"id": "%v",
-		"image_status": {
-			"error": {
-				"id": 10,
-				"details": null,
-				"reason": "osbuild build failed"
-			},
-			"status": "failure"
-		},
-		"status": "failure"
-	}`, jobId, jobId))
-}
-
 func TestComposeJobError(t *testing.T) {
 	srv, wrksrv, _, cancel := newV2Server(t, t.TempDir(), []string{""}, false)
 	defer cancel()
diff --git a/internal/store/json_test.go b/internal/store/json_test.go
index 19cd96cd8..bec573da9 100644
--- a/internal/store/json_test.go
+++ b/internal/store/json_test.go
@@ -1016,13 +1016,16 @@ func Test_newComposeV0(t *testing.T) {
 						{
 							Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 							ImageName: "",
-							Name:      "org.osbuild.local",
+							Name:      target.TargetNameAWS,
 							Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 							Status:    common.IBWaiting,
-							Options: target.LocalTargetOptions{
-								ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-								ImageBuildId: 0,
-								Filename:     "disk.qcow2",
+							OsbuildArtifact: target.OsbuildArtifact{
+								ExportFilename: "disk.qcow2",
+							},
+							Options: target.AWSTargetOptions{
+								Region: "us-east-1",
+								Bucket: "bucket",
+								Key:    "key",
 							},
 						},
 					},
@@ -1045,13 +1048,16 @@ func Test_newComposeV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1118,13 +1124,16 @@ func Test_newComposeFromV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1147,13 +1156,16 @@ func Test_newComposeFromV0(t *testing.T) {
 						{
 							Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 							ImageName: "",
-							Name:      "org.osbuild.local",
+							Name:      target.TargetNameAWS,
 							Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 							Status:    common.IBWaiting,
-							Options: target.LocalTargetOptions{
-								ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-								ImageBuildId: 0,
-								Filename:     "disk.qcow2",
+							OsbuildArtifact: target.OsbuildArtifact{
+								ExportFilename: "disk.qcow2",
+							},
+							Options: target.AWSTargetOptions{
+								Region: "us-east-1",
+								Bucket: "bucket",
+								Key:    "key",
 							},
 						},
 					},
@@ -1213,13 +1225,16 @@ func Test_newComposesV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1241,13 +1256,16 @@ func Test_newComposesV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1264,7 +1282,7 @@ func Test_newComposesV0(t *testing.T) {
 				uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"): {
 					Blueprint: &bp,
 					ImageBuilds: []imageBuildV0{
-						imageBuildV0{
+						{
 							ID:        0,
 							ImageType: test_distro.TestImageTypeName,
 							Manifest:  []byte("JSON MANIFEST GOES HERE"),
@@ -1272,13 +1290,16 @@ func Test_newComposesV0(t *testing.T) {
 								{
 									Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 									ImageName: "",
-									Name:      "org.osbuild.local",
+									Name:      target.TargetNameAWS,
 									Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 									Status:    common.IBWaiting,
-									Options: target.LocalTargetOptions{
-										ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-										ImageBuildId: 0,
-										Filename:     "disk.qcow2",
+									OsbuildArtifact: target.OsbuildArtifact{
+										ExportFilename: "disk.qcow2",
+									},
+									Options: target.AWSTargetOptions{
+										Region: "us-east-1",
+										Bucket: "bucket",
+										Key:    "key",
 									},
 								},
 							},
@@ -1295,7 +1316,7 @@ func Test_newComposesV0(t *testing.T) {
 				uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"): {
 					Blueprint: &bp,
 					ImageBuilds: []imageBuildV0{
-						imageBuildV0{
+						{
 							ID:        0,
 							ImageType: test_distro.TestImageTypeName,
 							Manifest:  []byte("JSON MANIFEST GOES HERE"),
@@ -1303,13 +1324,16 @@ func Test_newComposesV0(t *testing.T) {
 								{
 									Uuid:      uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
 									ImageName: "",
-									Name:      "org.osbuild.local",
+									Name:      target.TargetNameAWS,
 									Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 									Status:    common.IBWaiting,
-									Options: target.LocalTargetOptions{
-										ComposeId:    uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
-										ImageBuildId: 0,
-										Filename:     "disk.qcow2",
+									OsbuildArtifact: target.OsbuildArtifact{
+										ExportFilename: "disk.qcow2",
+									},
+									Options: target.AWSTargetOptions{
+										Region: "us-east-1",
+										Bucket: "bucket",
+										Key:    "key",
 									},
 								},
 							},
@@ -1375,13 +1399,16 @@ func Test_newComposesFromV0(t *testing.T) {
 								{
 									Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 									ImageName: "",
-									Name:      "org.osbuild.local",
+									Name:      target.TargetNameAWS,
 									Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 									Status:    common.IBWaiting,
-									Options: target.LocalTargetOptions{
-										ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-										ImageBuildId: 0,
-										Filename:     "disk.qcow2",
+									OsbuildArtifact: target.OsbuildArtifact{
+										ExportFilename: "disk.qcow2",
+									},
+									Options: target.AWSTargetOptions{
+										Region: "us-east-1",
+										Bucket: "bucket",
+										Key:    "key",
 									},
 								},
 							},
@@ -1405,13 +1432,16 @@ func Test_newComposesFromV0(t *testing.T) {
 								{
 									Uuid:      uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
 									ImageName: "",
-									Name:      "org.osbuild.local",
+									Name:      target.TargetNameAWS,
 									Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 									Status:    common.IBWaiting,
-									Options: target.LocalTargetOptions{
-										ComposeId:    uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
-										ImageBuildId: 0,
-										Filename:     "disk.qcow2",
+									OsbuildArtifact: target.OsbuildArtifact{
+										ExportFilename: "disk.qcow2",
+									},
+									Options: target.AWSTargetOptions{
+										Region: "us-east-1",
+										Bucket: "bucket",
+										Key:    "key",
 									},
 								},
 							},
@@ -1436,13 +1466,16 @@ func Test_newComposesFromV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1465,13 +1498,16 @@ func Test_newComposesFromV0(t *testing.T) {
 							{
 								Uuid:      uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
 								ImageName: "",
-								Name:      "org.osbuild.local",
+								Name:      target.TargetNameAWS,
 								Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 								Status:    common.IBWaiting,
-								Options: target.LocalTargetOptions{
-									ComposeId:    uuid.MustParse("14c454d0-26f3-4a56-8ceb-a5673aaba686"),
-									ImageBuildId: 0,
-									Filename:     "disk.qcow2",
+								OsbuildArtifact: target.OsbuildArtifact{
+									ExportFilename: "disk.qcow2",
+								},
+								Options: target.AWSTargetOptions{
+									Region: "us-east-1",
+									Bucket: "bucket",
+									Key:    "key",
 								},
 							},
 						},
@@ -1527,13 +1563,16 @@ func Test_newImageBuildFromV0(t *testing.T) {
 					{
 						Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 						ImageName: "",
-						Name:      "org.osbuild.local",
+						Name:      target.TargetNameAWS,
 						Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 						Status:    common.IBWaiting,
-						Options: target.LocalTargetOptions{
-							ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-							ImageBuildId: 0,
-							Filename:     "disk.qcow2",
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportFilename: "disk.qcow2",
+						},
+						Options: target.AWSTargetOptions{
+							Region: "us-east-1",
+							Bucket: "bucket",
+							Key:    "key",
 						},
 					},
 				},
@@ -1552,13 +1591,16 @@ func Test_newImageBuildFromV0(t *testing.T) {
 					{
 						Uuid:      uuid.MustParse("f53b49c0-d321-447e-8ab8-6e827891e3f0"),
 						ImageName: "",
-						Name:      "org.osbuild.local",
+						Name:      target.TargetNameAWS,
 						Created:   MustParseTime("2020-08-12T09:21:44.427717205-07:00"),
 						Status:    common.IBWaiting,
-						Options: target.LocalTargetOptions{
-							ComposeId:    uuid.MustParse("6b512b52-1e9d-4dac-869c-108fd4860a3e"),
-							ImageBuildId: 0,
-							Filename:     "disk.qcow2",
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportFilename: "disk.qcow2",
+						},
+						Options: target.AWSTargetOptions{
+							Region: "us-east-1",
+							Bucket: "bucket",
+							Key:    "key",
 						},
 					},
 				},
diff --git a/internal/target/target_test.go b/internal/target/target_test.go
new file mode 100644
index 000000000..216bd1362
--- /dev/null
+++ b/internal/target/target_test.go
@@ -0,0 +1,243 @@
+package target
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+// Test that `Filename` set in the `Target` options gets set also in the
+// `Target.ExportFilename`.
+// This covers the case when new worker receives a job from old composer.
+// This covers the case when new worker receives a job from new composer.
+func TestTargetOptionsFilenameCompatibilityUnmarshal(t *testing.T) {
+	testCases := []struct {
+		targetJSON     []byte
+		expectedTarget *Target
+	}{
+		// Test that Filename set in the target options gets set also in the ExportFilename
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.aws","options":{"filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name:    TargetNameAWS,
+				Options: &AWSTargetOptions{},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.aws.s3","options":{"region":"eu","accessKeyID":"id","secretAccessKey":"key","sessionToken":"token","bucket":"bkt","key":"key","endpoint":"endpoint","ca_bundle":"bundle","skip_ssl_verification":true,"filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameAWSS3,
+				Options: &AWSS3TargetOptions{
+					Region:              "eu",
+					AccessKeyID:         "id",
+					SecretAccessKey:     "key",
+					SessionToken:        "token",
+					Bucket:              "bkt",
+					Key:                 "key",
+					Endpoint:            "endpoint",
+					CABundle:            "bundle",
+					SkipSSLVerification: true,
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.azure","options":{"storageAccount":"account","storageAccessKey":"key","container":"container","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameAzure,
+				Options: &AzureTargetOptions{
+					StorageAccount:   "account",
+					StorageAccessKey: "key",
+					Container:        "container",
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.azure.image","options":{"tenant_id":"tenant","location":"location","subscription_id":"id","resource_group":"group","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameAzureImage,
+				Options: &AzureImageTargetOptions{
+					TenantID:       "tenant",
+					Location:       "location",
+					SubscriptionID: "id",
+					ResourceGroup:  "group",
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.gcp","options":{"region":"eu","os":"rhel-8","bucket":"bkt","object":"obj","shareWithAccounts":["account@domain.org"],"credentials":"","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameGCP,
+				Options: &GCPTargetOptions{
+					Region:            "eu",
+					Os:                "rhel-8",
+					Bucket:            "bkt",
+					Object:            "obj",
+					ShareWithAccounts: []string{"account@domain.org"},
+					Credentials:       []byte(""),
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.koji","options":{"upload_directory":"koji-dir","server":"koji.example.org","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameKoji,
+				Options: &KojiTargetOptions{
+					UploadDirectory: "koji-dir",
+					Server:          "koji.example.org",
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.oci","options":{"user":"user","tenancy":"tenant","region":"eu","fingerprint":"finger","private_key":"key","bucket":"bkt","namespace":"space","compartment_id":"compartment","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameOCI,
+				Options: &OCITargetOptions{
+					User:        "user",
+					Tenancy:     "tenant",
+					Region:      "eu",
+					Fingerprint: "finger",
+					PrivateKey:  "key",
+					Bucket:      "bkt",
+					Namespace:   "space",
+					Compartment: "compartment",
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.vmware","options":{"host":"example.org","username":"user","password":"pass","datacenter":"center","cluster":"cluster","datastore":"store","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameVMWare,
+				Options: &VMWareTargetOptions{
+					Host:       "example.org",
+					Username:   "user",
+					Password:   "pass",
+					Datacenter: "center",
+					Cluster:    "cluster",
+					Datastore:  "store",
+				},
+			},
+		},
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.container","options":{"reference":"ref","username":"user","password":"pass","filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameContainer,
+				Options: &ContainerTargetOptions{
+					Reference: "ref",
+					Username:  "user",
+					Password:  "pass",
+				},
+			},
+		},
+		// Test that the job as Marshalled by the current compatibility code is also acceptable.
+		// Such job has Filename set in the Target options, as well in the ExportFilename.
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.aws","export_filename":"image.qcow2","options":{"filename":"image.qcow2"}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name:    TargetNameAWS,
+				Options: &AWSTargetOptions{},
+			},
+		},
+		// Test the case if the compatibility code for Filename in the target options was dropped.
+		{
+			targetJSON: []byte(`{"image_name":"my-image","name":"org.osbuild.aws","osbuild_artifact":{"export_filename":"image.qcow2"},"options":{}}`),
+			expectedTarget: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name:    TargetNameAWS,
+				Options: &AWSTargetOptions{},
+			},
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
+			gotTarget := Target{}
+			err := json.Unmarshal(testCase.targetJSON, &gotTarget)
+			assert.NoError(t, err)
+			assert.EqualValues(t, testCase.expectedTarget, &gotTarget)
+		})
+	}
+}
+
+// Test that that ExportFilename set in the Target get added to the options
+// as Filename.
+// This enables old worker to still pick and be able to handle jobs from new composer.
+func TestTargetOptionsFilenameCompatibilityMarshal(t *testing.T) {
+	testCases := []struct {
+		targetJSON []byte
+		target     *Target
+	}{
+		{
+			targetJSON: []byte(`{"uuid":"00000000-0000-0000-0000-000000000000","image_name":"my-image","name":"org.osbuild.aws","created":"0001-01-01T00:00:00Z","status":"WAITING","options":{"region":"us","accessKeyID":"id","secretAccessKey":"key","sessionToken":"token","bucket":"bkt","key":"key","shareWithAccounts":["123456789"],"filename":"image.qcow2"},"osbuild_artifact":{"export_filename":"image.qcow2","export_name":""}}`),
+			target: &Target{
+				ImageName: "my-image",
+				OsbuildArtifact: OsbuildArtifact{
+					ExportFilename: "image.qcow2",
+				},
+				Name: TargetNameAWS,
+				Options: &AWSTargetOptions{
+					Region:            "us",
+					AccessKeyID:       "id",
+					SecretAccessKey:   "key",
+					SessionToken:      "token",
+					Bucket:            "bkt",
+					Key:               "key",
+					ShareWithAccounts: []string{"123456789"},
+				},
+			},
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
+			gotJSON, err := json.Marshal(testCase.target)
+			assert.Nil(t, err)
+			t.Logf("%s\n", gotJSON)
+			assert.EqualValues(t, testCase.targetJSON, gotJSON)
+		})
+	}
+}
diff --git a/internal/target/targetresult_test.go b/internal/target/targetresult_test.go
new file mode 100644
index 000000000..e364ff9eb
--- /dev/null
+++ b/internal/target/targetresult_test.go
@@ -0,0 +1,113 @@
+package target
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
+	"github.com/stretchr/testify/assert"
+)
+
+// Test that `Filename` set in the `Target` options gets set also in the
+// `Target.ExportFilename`.
+// This covers the case when new worker receives a job from old composer.
+// This covers the case when new worker receives a job from new composer.
+func TestTargetResultUnmarshal(t *testing.T) {
+	testCases := []struct {
+		resultJSON     []byte
+		expectedResult *TargetResult
+		err            bool
+	}{
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.aws","options":{"ami":"ami-123456789","region":"eu"}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameAWS,
+				Options: &AWSTargetResultOptions{
+					Ami:    "ami-123456789",
+					Region: "eu",
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.aws.s3","options":{"url":"https://example.org/image"}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameAWSS3,
+				Options: &AWSS3TargetResultOptions{
+					URL: "https://example.org/image",
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.gcp","options":{"image_name":"image","project_id":"project"}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameGCP,
+				Options: &GCPTargetResultOptions{
+					ImageName: "image",
+					ProjectID: "project",
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.azure.image","options":{"image_name":"image"}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameAzureImage,
+				Options: &AzureImageTargetResultOptions{
+					ImageName: "image",
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.koji","options":{"image_md5":"hash","image_size":123456}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameKoji,
+				Options: &KojiTargetResultOptions{
+					ImageMD5:  "hash",
+					ImageSize: 123456,
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.oci","options":{"region":"eu","image_id":"image"}}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameOCI,
+				Options: &OCITargetResultOptions{
+					Region:  "eu",
+					ImageID: "image",
+				},
+			},
+		},
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.vmware"}`),
+			expectedResult: &TargetResult{
+				Name: TargetNameVMWare,
+			},
+		},
+		// target results with error without options
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.aws","target_error":{"id":11,"reason":"failed to uplad image","details":["detail"]}}`),
+			expectedResult: &TargetResult{
+				Name:        TargetNameAWS,
+				TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to uplad image", "detail"),
+			},
+		},
+		// unknown target name
+		{
+			resultJSON: []byte(`{"name":"org.osbuild.made.up.target","options":{}}`),
+			err:        true,
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
+			gotResult := TargetResult{}
+			err := json.Unmarshal(testCase.resultJSON, &gotResult)
+			if testCase.err {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				assert.EqualValues(t, testCase.expectedResult, &gotResult)
+			}
+		})
+	}
+}
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index cb6a2dba3..d79160e95 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -685,6 +685,17 @@ func TestCompose(t *testing.T) {
 			QueueStatus: common.IBWaiting,
 			ImageType:   imgType,
 			Manifest:    manifest,
+			Targets: []*target.Target{
+				{
+					ImageName: imgType.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType.Filename(),
+						ExportName:     imgType.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+			},
 		},
 		Packages: dnfjson_mock.BaseDeps(),
 	}
@@ -703,11 +714,23 @@ func TestCompose(t *testing.T) {
 			Manifest:    manifest,
 			Targets: []*target.Target{
 				{
-					Name:      "org.osbuild.aws",
+					ImageName: imgType.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType.Filename(),
+						ExportName:     imgType.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+				{
+					Name:      target.TargetNameAWS,
 					Status:    common.IBWaiting,
 					ImageName: "test_upload",
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType.Filename(),
+						ExportName:     imgType.Exports()[0],
+					},
 					Options: &target.AWSTargetOptions{
-						Filename:        "test.img",
 						Region:          "frankfurt",
 						AccessKeyID:     "accesskey",
 						SecretAccessKey: "secretkey",
@@ -732,6 +755,17 @@ func TestCompose(t *testing.T) {
 			QueueStatus: common.IBWaiting,
 			ImageType:   imgType,
 			Manifest:    manifest,
+			Targets: []*target.Target{
+				{
+					ImageName: imgType.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType.Filename(),
+						ExportName:     imgType.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+			},
 		},
 		Packages: dnfjson_mock.BaseDeps(),
 	}
@@ -758,6 +792,17 @@ func TestCompose(t *testing.T) {
 			QueueStatus: common.IBWaiting,
 			ImageType:   imgType2,
 			Manifest:    manifest2,
+			Targets: []*target.Target{
+				{
+					ImageName: imgType2.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType2.Filename(),
+						ExportName:     imgType2.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+			},
 		},
 		Packages: dnfjson_mock.BaseDeps(),
 	}
@@ -1688,6 +1733,17 @@ func TestComposePOST_ImageTypeDenylist(t *testing.T) {
 			QueueStatus: common.IBWaiting,
 			ImageType:   imgType,
 			Manifest:    manifest,
+			Targets: []*target.Target{
+				{
+					ImageName: imgType.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType.Filename(),
+						ExportName:     imgType.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+			},
 		},
 		Packages: dnfjson_mock.BaseDeps(),
 	}
@@ -1705,6 +1761,17 @@ func TestComposePOST_ImageTypeDenylist(t *testing.T) {
 			QueueStatus: common.IBWaiting,
 			ImageType:   imgType2,
 			Manifest:    manifest,
+			Targets: []*target.Target{
+				{
+					ImageName: imgType2.Filename(),
+					OsbuildArtifact: target.OsbuildArtifact{
+						ExportFilename: imgType2.Filename(),
+						ExportName:     imgType2.Exports()[0],
+					},
+					Name:    target.TargetNameWorkerServer,
+					Options: &target.WorkerServerTargetOptions{},
+				},
+			},
 		},
 		Packages: dnfjson_mock.BaseDeps(),
 	}
diff --git a/internal/weldr/compose_test.go b/internal/weldr/compose_test.go
index a2c2bf5ce..d90ca4076 100644
--- a/internal/weldr/compose_test.go
+++ b/internal/weldr/compose_test.go
@@ -44,7 +44,14 @@ func TestComposeStatusFromLegacyError(t *testing.T) {
 	require.NoError(t, err)
 	require.Equal(t, jobId, j)
 
-	jobResult := worker.OSBuildJobResult{TargetErrors: []string{"Upload error"}}
+	jobResult := worker.OSBuildJobResult{
+		JobResult: worker.JobResult{
+			JobError: &clienterrors.Error{
+				ID:     clienterrors.ErrorUploadingImage,
+				Reason: "Upload error",
+			},
+		},
+	}
 	rawResult, err := json.Marshal(jobResult)
 	require.NoError(t, err)
 	err = api.workers.FinishJob(token, rawResult)
diff --git a/internal/worker/json_test.go b/internal/worker/json_test.go
new file mode 100644
index 000000000..6f629e27f
--- /dev/null
+++ b/internal/worker/json_test.go
@@ -0,0 +1,335 @@
+package worker
+
+import (
+	"encoding/json"
+	"fmt"
+	"testing"
+
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	"github.com/osbuild/osbuild-composer/internal/target"
+	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOSBuildJobResultTargetErrors(t *testing.T) {
+	testCases := []struct {
+		jobResult    OSBuildJobResult
+		targetErrors []*clienterrors.Error
+	}{
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name:        target.TargetNameAWS,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+					},
+					{
+						Name:        target.TargetNameVMWare,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare"),
+					},
+					{
+						Name:        target.TargetNameAWSS3,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3"),
+					},
+				},
+			},
+			targetErrors: []*clienterrors.Error{
+				clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS", target.TargetNameAWS),
+				clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "can't upload image to VMWare", target.TargetNameVMWare),
+				clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3", target.TargetNameAWSS3),
+			},
+		},
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name:        target.TargetNameAWS,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS"),
+					},
+					{
+						Name: target.TargetNameVMWare,
+					},
+					{
+						Name:        target.TargetNameAWSS3,
+						TargetError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3"),
+					},
+				},
+			},
+			targetErrors: []*clienterrors.Error{
+				clienterrors.WorkerClientError(clienterrors.ErrorInvalidTargetConfig, "can't login to AWS", target.TargetNameAWS),
+				clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "failed to upload image to AWS S3", target.TargetNameAWSS3),
+			},
+		},
+		{
+			jobResult: OSBuildJobResult{
+				TargetResults: []*target.TargetResult{
+					{
+						Name: target.TargetNameAWS,
+					},
+					{
+						Name: target.TargetNameVMWare,
+					},
+					{
+						Name: target.TargetNameAWSS3,
+					},
+				},
+			},
+			targetErrors: []*clienterrors.Error{},
+		},
+		{
+			jobResult:    OSBuildJobResult{},
+			targetErrors: []*clienterrors.Error{},
+		},
+	}
+
+	for _, testCase := range testCases {
+		assert.EqualValues(t, testCase.targetErrors, testCase.jobResult.TargetErrors())
+	}
+}
+
+func TestOSBuildJobExports(t *testing.T) {
+	testCases := []struct {
+		job             *OSBuildJob
+		expectedExports []string
+	}{
+		// one target with export set
+		{
+			job: &OSBuildJob{
+				Manifest: []byte("manifest"),
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+					},
+				},
+			},
+			expectedExports: []string{"archive"},
+		},
+		// multiple targets with different exports set
+		{
+			job: &OSBuildJob{
+				Manifest: []byte("manifest"),
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+					},
+					{
+						Name: target.TargetNameAWSS3,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "image",
+						},
+					},
+				},
+			},
+			expectedExports: []string{"archive", "image"},
+		},
+		// multiple targets with the same export
+		{
+			job: &OSBuildJob{
+				Manifest: []byte("manifest"),
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+					},
+					{
+						Name: target.TargetNameAWSS3,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+					},
+				},
+			},
+			expectedExports: []string{"archive"},
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("case #%d", idx), func(t *testing.T) {
+			assert.EqualValues(t, testCase.expectedExports, testCase.job.OsbuildExports())
+		})
+	}
+}
+
+// Test that that exports set in the OSBuildJob get added to all targets
+// defined in the job.
+// This covers the case when new worker receives a job from old composer.
+// This covers the case when new worker receives a job from new composer.
+func TestOSBuildJobExportsCompatibilityUnmarshal(t *testing.T) {
+	testCases := []struct {
+		jobJSON         []byte
+		job             *OSBuildJob
+		expectedExports []string
+		err             bool
+	}{
+		// Test that one export specified on the job level gets added to each target
+		{
+			jobJSON: []byte(`{"export_stages":["archive"],"targets":[{"name":"org.osbuild.aws","options":{}}]}`),
+			job: &OSBuildJob{
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSTargetOptions{},
+					},
+				},
+				PipelineNames: &PipelineNames{
+					Build:   distro.BuildPipelinesFallback(),
+					Payload: distro.PayloadPipelinesFallback(),
+				},
+			},
+			expectedExports: []string{"archive"},
+		},
+		// Test that one export specified on the job level gets added to each target
+		{
+			jobJSON: []byte(`{"export_stages":["archive"],"targets":[{"name":"org.osbuild.aws","options":{}},{"name":"org.osbuild.aws.s3","options":{}}]}`),
+			job: &OSBuildJob{
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSTargetOptions{},
+					},
+					{
+						Name: target.TargetNameAWSS3,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSS3TargetOptions{},
+					},
+				},
+				PipelineNames: &PipelineNames{
+					Build:   distro.BuildPipelinesFallback(),
+					Payload: distro.PayloadPipelinesFallback(),
+				},
+			},
+			expectedExports: []string{"archive"},
+		},
+		// Test that the job as Marshalled by the current compatibility code is also acceptable/
+		// Such job has exports set on the job level, but also in the targets
+		{
+			jobJSON: []byte(`{"export_stages":["archive"],"targets":[{"name":"org.osbuild.aws","options":{},"osbuild_export":"archive"},{"name":"org.osbuild.aws.s3","options":{},"osbuild_export":"archive"}]}`),
+			job: &OSBuildJob{
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSTargetOptions{},
+					},
+					{
+						Name: target.TargetNameAWSS3,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSS3TargetOptions{},
+					},
+				},
+				PipelineNames: &PipelineNames{
+					Build:   distro.BuildPipelinesFallback(),
+					Payload: distro.PayloadPipelinesFallback(),
+				},
+			},
+			expectedExports: []string{"archive"},
+		},
+		// Test that different exports defined on the job and target level generate an error
+		{
+			jobJSON: []byte(`{"export_stages":["archive"],"targets":[{"name":"org.osbuild.aws","options":{},"osbuild_artifact":{"export_name":"image"}},{"name":"org.osbuild.aws.s3","options":{},"osbuild_artifact":{"export_name":"archive"}}]}`),
+			err:     true,
+		},
+		// Test that different exports defined on the job and target level generate an error
+		{
+			jobJSON: []byte(`{"export_stages":["image"],"targets":[{"name":"org.osbuild.aws","options":{},"osbuild_artifact":{"export_name":"archive"}},{"name":"org.osbuild.aws.s3","options":{},"osbuild_artifact":{"export_name":"archive"}}]}`),
+			err:     true,
+		},
+		// Test that multiple exports defined on the job level generate an error
+		{
+			jobJSON: []byte(`{"export_stages":["archive","image"],"targets":[{"name":"org.osbuild.aws","options":{},"osbuild_export":"archive"},{"name":"org.osbuild.aws.s3","options":{},"osbuild_export":"archive"}]}`),
+			err:     true,
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
+			gotJob := OSBuildJob{}
+			err := json.Unmarshal(testCase.jobJSON, &gotJob)
+			if testCase.err {
+				assert.Error(t, err)
+			} else {
+				assert.Nil(t, err)
+				assert.EqualValues(t, testCase.job, &gotJob)
+				assert.EqualValues(t, testCase.expectedExports, gotJob.OsbuildExports())
+			}
+		})
+	}
+}
+
+// Test that that exports set in the OSBuildJob target get added to the job
+// definition itself.
+// This covers the case when jobs from new composer are to be picked by old worker.
+// This covers the case when new worker receives a job from new composer.
+func TestOSBuildJobExportsCompatibilityMarshal(t *testing.T) {
+	testCases := []struct {
+		jobJSON []byte
+		job     *OSBuildJob
+	}{
+		// Test that the export specified in the target is set also on the job level as it used to be in the past
+		{
+			jobJSON: []byte(`{"targets":[{"uuid":"00000000-0000-0000-0000-000000000000","image_name":"","name":"org.osbuild.aws","created":"0001-01-01T00:00:00Z","status":"WAITING","options":{"region":"","accessKeyID":"","secretAccessKey":"","sessionToken":"","bucket":"","key":"","shareWithAccounts":null,"filename":""},"osbuild_artifact":{"export_filename":"","export_name":"archive"}}],"export_stages":["archive"]}`),
+			job: &OSBuildJob{
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSTargetOptions{},
+					},
+				},
+			},
+		},
+		// Test that the export specified in multiple targets is set also on the job level as it used to be in the past.
+		// We do not test with multiple different exports, because multiple exports in job definition were never allowed.
+		{
+			jobJSON: []byte(`{"targets":[{"uuid":"00000000-0000-0000-0000-000000000000","image_name":"","name":"org.osbuild.aws","created":"0001-01-01T00:00:00Z","status":"WAITING","options":{"region":"","accessKeyID":"","secretAccessKey":"","sessionToken":"","bucket":"","key":"","shareWithAccounts":null,"filename":""},"osbuild_artifact":{"export_filename":"","export_name":"archive"}},{"uuid":"00000000-0000-0000-0000-000000000000","image_name":"","name":"org.osbuild.aws.s3","created":"0001-01-01T00:00:00Z","status":"WAITING","options":{"region":"","accessKeyID":"","secretAccessKey":"","sessionToken":"","bucket":"","key":"","endpoint":"","ca_bundle":"","skip_ssl_verification":false,"filename":""},"osbuild_artifact":{"export_filename":"","export_name":"archive"}}],"export_stages":["archive"]}`),
+			job: &OSBuildJob{
+				Targets: []*target.Target{
+					{
+						Name: target.TargetNameAWS,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSTargetOptions{},
+					},
+					{
+						Name: target.TargetNameAWSS3,
+						OsbuildArtifact: target.OsbuildArtifact{
+							ExportName: "archive",
+						},
+						Options: &target.AWSS3TargetOptions{},
+					},
+				},
+			},
+		},
+	}
+
+	for idx, testCase := range testCases {
+		t.Run(fmt.Sprintf("Case #%d", idx), func(t *testing.T) {
+			gotJSON, err := json.Marshal(testCase.job)
+			assert.Nil(t, err)
+			assert.EqualValues(t, testCase.jobJSON, gotJSON)
+		})
+	}
+}
diff --git a/internal/worker/server_test.go b/internal/worker/server_test.go
index c855b0630..f6b3b9575 100644
--- a/internal/worker/server_test.go
+++ b/internal/worker/server_test.go
@@ -20,6 +20,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/jobqueue/fsjobqueue"
 	"github.com/osbuild/osbuild-composer/internal/osbuild2"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/worker"
 	"github.com/osbuild/osbuild-composer/internal/worker/clienterrors"
@@ -223,12 +224,22 @@ func TestArgs(t *testing.T) {
 	server := newTestServer(t, t.TempDir(), time.Duration(0), "/api/worker/v1", false)
 
 	job := worker.OSBuildJob{
-		Manifest:  manifest,
-		ImageName: "test-image",
+		Manifest: manifest,
 		PipelineNames: &worker.PipelineNames{
 			Build:   []string{"b"},
 			Payload: []string{"x", "y", "z"},
 		},
+		Targets: []*target.Target{
+			{
+				Name:      target.TargetNameWorkerServer,
+				ImageName: "test-image",
+				OsbuildArtifact: target.OsbuildArtifact{
+					ExportFilename: "test-image",
+					ExportName:     "assembler",
+				},
+				Options: &target.WorkerServerTargetOptions{},
+			},
+		},
 	}
 	jobId, err := server.EnqueueOSBuild(arch.Name(), &job, "")
 	require.NoError(t, err)
@@ -399,19 +410,39 @@ func TestMixedOSBuildJob(t *testing.T) {
 	fbPipelines := &worker.PipelineNames{Build: distro.BuildPipelinesFallback(), Payload: distro.PayloadPipelinesFallback()}
 
 	oldJob := worker.OSBuildJob{
-		Manifest:  emptyManifestV2,
-		ImageName: "no-pipeline-names",
+		Manifest: emptyManifestV2,
+		Targets: []*target.Target{
+			{
+				Name:      target.TargetNameWorkerServer,
+				ImageName: "no-pipeline-names",
+				OsbuildArtifact: target.OsbuildArtifact{
+					ExportFilename: "no-pipeline-names",
+					ExportName:     "assembler",
+				},
+				Options: &target.WorkerServerTargetOptions{},
+			},
+		},
 	}
 	oldJobID, err := server.EnqueueOSBuild("x", &oldJob, "")
 	require.NoError(err)
 
 	newJob := worker.OSBuildJob{
-		Manifest:  emptyManifestV2,
-		ImageName: "with-pipeline-names",
+		Manifest: emptyManifestV2,
 		PipelineNames: &worker.PipelineNames{
 			Build:   []string{"build"},
 			Payload: []string{"other", "pipelines"},
 		},
+		Targets: []*target.Target{
+			{
+				Name:      target.TargetNameWorkerServer,
+				ImageName: "with-pipeline-names",
+				OsbuildArtifact: target.OsbuildArtifact{
+					ExportFilename: "with-pipeline-names",
+					ExportName:     "assembler",
+				},
+				Options: &target.WorkerServerTargetOptions{},
+			},
+		},
 	}
 	newJobID, err := server.EnqueueOSBuild("x", &newJob, "")
 	require.NoError(err)
@@ -423,7 +454,7 @@ func TestMixedOSBuildJob(t *testing.T) {
 	// OldJob gets default pipeline names when read
 	require.Equal(fbPipelines, oldJobRead.PipelineNames)
 	require.Equal(oldJob.Manifest, oldJobRead.Manifest)
-	require.Equal(oldJob.ImageName, oldJobRead.ImageName)
+	require.Equal(oldJob.Targets, oldJobRead.Targets)
 	// Not entirely equal
 	require.NotEqual(oldJob, oldJobRead)
 
@@ -719,110 +750,6 @@ func TestDepsolveLegacyErrorConversion(t *testing.T) {
 	require.Equal(t, expectedResult, depsolveJobResult)
 }
 
-// Enquueue OSBuild jobs and save both kinds of
-// error types (old & new) to the queue and ensure
-// that both kinds of errors can be read back
-func TestMixedOSBuildJobErrors(t *testing.T) {
-	require := require.New(t)
-
-	emptyManifestV2 := distro.Manifest(`{"version":"2","pipelines":{}}`)
-	server := newTestServer(t, t.TempDir(), time.Millisecond*10, "/", false)
-
-	oldJob := worker.OSBuildJob{
-		Manifest:  emptyManifestV2,
-		ImageName: "no-pipeline-names",
-	}
-	oldJobID, err := server.EnqueueOSBuild("x", &oldJob, "")
-	require.NoError(err)
-
-	newJob := worker.OSBuildJob{
-		Manifest:  emptyManifestV2,
-		ImageName: "with-pipeline-names",
-		PipelineNames: &worker.PipelineNames{
-			Build:   []string{"build"},
-			Payload: []string{"other", "pipelines"},
-		},
-	}
-	newJobID, err := server.EnqueueOSBuild("x", &newJob, "")
-	require.NoError(err)
-
-	oldJobRead := new(worker.OSBuildJob)
-	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.OSBuildJob(newJobID, newJobRead)
-	require.NoError(err)
-
-	// Dequeue the jobs (via RequestJob) to get their tokens and update them to
-	// test the result retrieval
-
-	getJob := func() (uuid.UUID, uuid.UUID) {
-		// don't block forever if the jobs weren't added or can't be retrieved
-		ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
-		defer cancel()
-		id, token, _, _, _, err := server.RequestJob(ctx, "x", []string{worker.JobTypeOSBuild}, []string{""})
-		require.NoError(err)
-		return id, token
-	}
-
-	getJobTokens := func(n uint) map[uuid.UUID]uuid.UUID {
-		tokens := make(map[uuid.UUID]uuid.UUID, n)
-		for idx := uint(0); idx < n; idx++ {
-			id, token := getJob()
-			tokens[id] = token
-		}
-		return tokens
-	}
-
-	jobTokens := getJobTokens(2)
-	// make sure we got them both as expected
-	require.Contains(jobTokens, oldJobID)
-	require.Contains(jobTokens, newJobID)
-
-	oldJobResult := &worker.OSBuildJobResult{
-		TargetErrors: []string{"Upload error"},
-	}
-	oldJobResultRaw, err := json.Marshal(oldJobResult)
-	require.NoError(err)
-	oldJobToken := jobTokens[oldJobID]
-	err = server.FinishJob(oldJobToken, oldJobResultRaw)
-	require.NoError(err)
-
-	oldJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.OSBuildJobStatus(oldJobID, oldJobResultRead)
-	require.NoError(err)
-
-	require.NotEqual(oldJobResult, oldJobResultRead)
-	require.Equal(oldJobResult.Success, false)
-	require.Equal(oldJobResultRead.Success, false)
-
-	newJobResult := &worker.OSBuildJobResult{
-		PipelineNames: &worker.PipelineNames{
-			Build:   []string{"build-result"},
-			Payload: []string{"result-test-payload", "result-test-assembler"},
-		},
-		JobResult: worker.JobResult{
-			JobError: clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, "Error uploading image", nil),
-		},
-	}
-	newJobResultRaw, err := json.Marshal(newJobResult)
-	require.NoError(err)
-	newJobToken := jobTokens[newJobID]
-	err = server.FinishJob(newJobToken, newJobResultRaw)
-	require.NoError(err)
-
-	newJobResultRead := new(worker.OSBuildJobResult)
-	_, _, err = server.OSBuildJobStatus(newJobID, newJobResultRead)
-	require.NoError(err)
-	require.Equal(newJobResult, newJobResultRead)
-	require.Equal(newJobResult.Success, false)
-	require.Equal(newJobResultRead.Success, false)
-}
-
 // Enquueue Koji jobs and save both kinds of
 // error types (old & new) to the queue and ensure
 // that both kinds of errors can be read back