Repository: osbuild/osbuild-composer
Pull request #131 (2019-11-28 14:28:37)

Add upload support to API
Unfortunately, a few not so related changes got into this PR, sorry, it was hard to split up.

IMO the most radical change is the move of ComposeEntry and ListQueue outside of the store. To me it makes sense, because ComposeEntry is more related to the API. I am open to discussion if you don't like that.


Patch:
diff --git a/.packit.yaml b/.packit.yaml
new file mode 100644
index 000000000..fa15cc9a9
--- /dev/null
+++ b/.packit.yaml
@@ -0,0 +1,31 @@
+# we have the specfile in the root of our repository
+specfile_path: golang-github-osbuild-composer.spec
+# when doing an update in Fedora, we want to copy the spec file and the config file
+synced_files:
+  - osbuild.spec
+  - .packit.yaml
+upstream_package_name: osbuild-composer
+downstream_package_name: golang-github-osbuild-composer
+jobs:
+  # trigger a COPR build for push events in open PRs
+  - job: copr_build
+    trigger: pull_request
+    metadata:
+      targets:
+        - fedora-31-x86_64
+        - fedora-rawhide-x86_64
+  # this is triggered by a commit on src.fedoraproject.org, not Github!
+  # e.g. in case of mass rebuild or automated changes of spec file (e.g. Python packagers)
+  # it will create a PR on Github with changes in synchronized files
+  - job: sync_from_downstream
+    trigger: commit
+  # create a PR on src.fedoraproject.org containing updated spec file and new sources (tarball)
+  # triggered by Github release
+  - job: propose_downstream
+    trigger: release
+    metadata:
+      dist-git-branch: master
+  - job: propose_downstream
+    trigger: release
+    metadata:
+      dist-git-branch: f31
diff --git a/cmd/osbuild-upload-azure/main.go b/cmd/osbuild-upload-azure/main.go
index 779a2e13b..74e03f581 100644
--- a/cmd/osbuild-upload-azure/main.go
+++ b/cmd/osbuild-upload-azure/main.go
@@ -35,7 +35,7 @@ func main() {
 
 	fmt.Println("Image to upload is:", fileName)
 
-	azure.UploadImage(azure.Credentials{
+	err := azure.UploadImage(azure.Credentials{
 		StorageAccount:   storageAccount,
 		StorageAccessKey: storageAccessKey,
 	}, azure.ImageMetadata{
@@ -43,4 +43,7 @@ func main() {
 		ContainerName: containerName,
 	}, fileName, threads)
 
+	if err != nil {
+		fmt.Println("Error: ", err)
+	}
 }
diff --git a/go.mod b/go.mod
index 97d9b69ae..2bf27de35 100644
--- a/go.mod
+++ b/go.mod
@@ -7,6 +7,7 @@ require (
 	github.com/aws/aws-sdk-go v1.25.37
 	github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f
 	github.com/gobwas/glob v0.2.3
+	github.com/google/go-cmp v0.3.1
 	github.com/google/uuid v1.1.1
 	github.com/julienschmidt/httprouter v1.2.0
 )
diff --git a/go.sum b/go.sum
index 163bc1388..cc48dc82a 100644
--- a/go.sum
+++ b/go.sum
@@ -1,13 +1,15 @@
-github.com/aws/aws-sdk-go v1.25.37 h1:gBtB/F3dophWpsUQKN/Kni+JzYEH2mGHF4hWNtfED1w=
-github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZOMdj5HYo=
 github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4=
 github.com/Azure/azure-storage-blob-go v0.8.0 h1:53qhf0Oxa0nOjgbDeeYPUeyiNmafAFEY95rZLK0Tj6o=
 github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
+github.com/aws/aws-sdk-go v1.25.37 h1:gBtB/F3dophWpsUQKN/Kni+JzYEH2mGHF4hWNtfED1w=
+github.com/aws/aws-sdk-go v1.25.37/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
+github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
+github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
diff --git a/golang-github-osbuild-composer.spec b/golang-github-osbuild-composer.spec
index 8ed2f44c3..d0ee0c3df 100644
--- a/golang-github-osbuild-composer.spec
+++ b/golang-github-osbuild-composer.spec
@@ -27,6 +27,7 @@ BuildRequires:  golang(github.com/coreos/go-systemd/activation)
 BuildRequires:  golang(github.com/google/uuid)
 BuildRequires:  golang(github.com/julienschmidt/httprouter)
 BuildRequires:  golang(github.com/gobwas/glob)
+BuildRequires:  golang(github.com/google/go-cmp/cmp)
 
 Requires: systemd
 Requires: osbuild
diff --git a/internal/distro/fedora30/distro.go b/internal/distro/fedora30/distro.go
index 6e10b76b0..181b2842c 100644
--- a/internal/distro/fedora30/distro.go
+++ b/internal/distro/fedora30/distro.go
@@ -35,7 +35,7 @@ func New() *Fedora30 {
 	}
 
 	r.outputs["ami"] = output{
-		Name:     "image.ami",
+		Name:     "image.raw.xz",
 		MimeType: "application/octet-stream",
 		Packages: []string{
 			"@Core",
@@ -58,7 +58,7 @@ func New() *Fedora30 {
 		},
 		KernelOptions: "ro no_timer_check console=ttyS0,115200n8 console=tty1 biosdevname=0 net.ifnames=0 console=ttyS0,115200",
 		IncludeFSTab:  true,
-		Assembler:     r.qemuAssembler("raw", "image.ami"),
+		Assembler:     r.qemuAssembler("raw.xz", "image.raw.xz"),
 	}
 
 	r.outputs["ext4-filesystem"] = output{
diff --git a/internal/jobqueue/job.go b/internal/jobqueue/job.go
index 3c650d92a..7d6c47729 100644
--- a/internal/jobqueue/job.go
+++ b/internal/jobqueue/job.go
@@ -109,7 +109,11 @@ func (job *Job) Run(d distro.Distro) (error, []error) {
 				continue
 			}
 
-			_, err = a.Upload("/var/cache/osbuild-composer/store/refs/"+result.OutputID+"/image.ami", options.Bucket, options.Key)
+			if options.Key == "" {
+				options.Key = job.ID.String()
+			}
+
+			_, err = a.Upload("/var/cache/osbuild-composer/store/refs/"+result.OutputID+"/image.raw.xz", options.Bucket, options.Key)
 			if err != nil {
 				r = append(r, err)
 				continue
diff --git a/internal/mocks/rpmmd/fixtures.go b/internal/mocks/rpmmd/fixtures.go
index 65c4662ff..2bfbb9504 100644
--- a/internal/mocks/rpmmd/fixtures.go
+++ b/internal/mocks/rpmmd/fixtures.go
@@ -10,6 +10,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
+	"github.com/osbuild/osbuild-composer/internal/target"
 )
 
 type FixtureGenerator func() Fixture
@@ -54,44 +55,78 @@ func generatePackageList() rpmmd.PackageList {
 
 func createBaseStoreFixture() *store.Store {
 	var bName = "test"
-	var b = blueprint.Blueprint{Name: bName, Version: "0.0.0"}
+	var b = blueprint.Blueprint{
+		Name:           bName,
+		Version:        "0.0.0",
+		Packages:       []blueprint.Package{},
+		Modules:        []blueprint.Package{},
+		Groups:         []blueprint.Group{},
+		Customizations: nil,
+	}
 
 	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:    "WAITING",
+		Options: &target.LocalTargetOptions{
+			Location: "/tmp/localimage",
+		},
+	}
+
+	var awsTarget = &target.Target{
+		Uuid:      uuid.MustParse("10000000-0000-0000-0000-000000000000"),
+		Name:      "org.osbuild.aws",
+		ImageName: "awsimage",
+		Created:   date,
+		Status:    "WAITING",
+		Options: &target.AWSTargetOptions{
+			Region:          "frankfurt",
+			AccessKeyID:     "accesskey",
+			SecretAccessKey: "secretkey",
+			Bucket:          "clay",
+			Key:             "imagekey",
+		},
+	}
+
 	d := distro.New("fedora-30")
 	s := store.New(nil, d)
 
 	s.Blueprints[bName] = b
 	s.Composes = map[uuid.UUID]store.Compose{
-		uuid.MustParse("e65f76f8-b0d9-4974-9dd7-745ae80b4721"): store.Compose{
+		uuid.MustParse("30000000-0000-0000-0000-000000000000"): store.Compose{
 			QueueStatus: "WAITING",
 			Blueprint:   &b,
 			OutputType:  "tar",
-			Targets:     nil,
+			Targets:     []*target.Target{localTarget, awsTarget},
 			JobCreated:  date,
 		},
-		uuid.MustParse("e65f76f8-b0d9-4974-9dd7-745ae80b4722"): store.Compose{
+		uuid.MustParse("30000000-0000-0000-0000-000000000001"): store.Compose{
 			QueueStatus: "RUNNING",
 			Blueprint:   &b,
 			OutputType:  "tar",
-			Targets:     nil,
+			Targets:     []*target.Target{localTarget},
 			JobCreated:  date,
 			JobStarted:  date,
 		},
-		uuid.MustParse("e65f76f8-b0d9-4974-9dd7-745ae80b4723"): store.Compose{
+		uuid.MustParse("30000000-0000-0000-0000-000000000002"): store.Compose{
 			QueueStatus: "FINISHED",
 			Blueprint:   &b,
 			OutputType:  "tar",
-			Targets:     nil,
+			Targets:     []*target.Target{localTarget, awsTarget},
 			JobCreated:  date,
 			JobStarted:  date,
 			JobFinished: date,
 		},
-		uuid.MustParse("e65f76f8-b0d9-4974-9dd7-745ae80b4724"): store.Compose{
+		uuid.MustParse("30000000-0000-0000-0000-000000000003"): store.Compose{
 			QueueStatus: "FAILED",
 			Blueprint:   &b,
 			OutputType:  "tar",
-			Targets:     nil,
+			Targets:     []*target.Target{localTarget, awsTarget},
 			JobCreated:  date,
 			JobStarted:  date,
 			JobFinished: date,
@@ -101,6 +136,44 @@ func createBaseStoreFixture() *store.Store {
 	return s
 }
 
+func createBaseDepsolveFixture() []rpmmd.PackageSpec {
+	return []rpmmd.PackageSpec{
+		{
+			Name:    "dep-package1",
+			Epoch:   0,
+			Version: "1.33",
+			Release: "2.fc30",
+			Arch:    "x86_64",
+		},
+		{
+			Name:    "dep-package2",
+			Epoch:   0,
+			Version: "2.9",
+			Release: "1.fc30",
+			Arch:    "x86_64",
+		},
+	}
+}
+
+func createStoreWithoutComposesFixture() *store.Store {
+	var bName = "test"
+	var b = blueprint.Blueprint{
+		Name:           bName,
+		Version:        "0.0.0",
+		Packages:       []blueprint.Package{},
+		Modules:        []blueprint.Package{},
+		Groups:         []blueprint.Group{},
+		Customizations: nil,
+	}
+
+	d := distro.New("fedora-30")
+	s := store.New(nil, d)
+
+	s.Blueprints[bName] = b
+
+	return s
+}
+
 func BaseFixture() Fixture {
 	return Fixture{
 		fetchPackageList{
@@ -108,28 +181,27 @@ func BaseFixture() Fixture {
 			nil,
 		},
 		depsolve{
-			[]rpmmd.PackageSpec{
-				{
-					Name:    "dep-package1",
-					Epoch:   0,
-					Version: "1.33",
-					Release: "2.fc30",
-					Arch:    "x86_64",
-				},
-				{
-					Name:    "dep-package2",
-					Epoch:   0,
-					Version: "2.9",
-					Release: "1.fc30",
-					Arch:    "x86_64",
-				},
-			},
+			createBaseDepsolveFixture(),
 			nil,
 		},
 		createBaseStoreFixture(),
 	}
 }
 
+func NoComposesFixture() Fixture {
+	return Fixture{
+		fetchPackageList{
+			generatePackageList(),
+			nil,
+		},
+		depsolve{
+			createBaseDepsolveFixture(),
+			nil,
+		},
+		createStoreWithoutComposesFixture(),
+	}
+}
+
 func NonExistingPackage() Fixture {
 	return Fixture{
 		fetchPackageList{
diff --git a/internal/store/store.go b/internal/store/store.go
index 5950b835f..8fc57dc71 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -220,89 +220,25 @@ func (s *Store) ListBlueprints() []string {
 	return names
 }
 
-type ComposeEntry struct {
-	ID          uuid.UUID `json:"id"`
-	Blueprint   string    `json:"blueprint"`
-	Version     string    `json:"version"`
-	ComposeType string    `json:"compose_type"`
-	ImageSize   int64     `json:"image_size"`
-	QueueStatus string    `json:"queue_status"`
-	JobCreated  float64   `json:"job_created"`
-	JobStarted  float64   `json:"job_started,omitempty"`
-	JobFinished float64   `json:"job_finished,omitempty"`
-}
-
-func (s *Store) ListQueue(uuids []uuid.UUID) []*ComposeEntry {
+func (s *Store) GetAllComposes() map[uuid.UUID]Compose {
 	s.mu.RLock()
 	defer s.mu.RUnlock()
 
-	newCompose := func(id uuid.UUID, compose Compose) *ComposeEntry {
-		switch compose.QueueStatus {
-		case "WAITING":
-			return &ComposeEntry{
-				ID:          id,
-				Blueprint:   compose.Blueprint.Name,
-				Version:     compose.Blueprint.Version,
-				ComposeType: compose.OutputType,
-				QueueStatus: compose.QueueStatus,
-				JobCreated:  float64(compose.JobCreated.UnixNano()) / 1000000000,
-			}
-		case "RUNNING":
-			return &ComposeEntry{
-				ID:          id,
-				Blueprint:   compose.Blueprint.Name,
-				Version:     compose.Blueprint.Version,
-				ComposeType: compose.OutputType,
-				QueueStatus: compose.QueueStatus,
-				JobCreated:  float64(compose.JobCreated.UnixNano()) / 1000000000,
-				JobStarted:  float64(compose.JobStarted.UnixNano()) / 1000000000,
-			}
-		case "FINISHED":
-			image, err := s.GetImage(id)
-			imageSize := int64(0)
-			if err == nil {
-				imageSize = image.Size
-			}
-			return &ComposeEntry{
-				ID:          id,
-				Blueprint:   compose.Blueprint.Name,
-				Version:     compose.Blueprint.Version,
-				ComposeType: compose.OutputType,
-				ImageSize:   imageSize,
-				QueueStatus: compose.QueueStatus,
-				JobCreated:  float64(compose.JobCreated.UnixNano()) / 1000000000,
-				JobStarted:  float64(compose.JobStarted.UnixNano()) / 1000000000,
-				JobFinished: float64(compose.JobFinished.UnixNano()) / 1000000000,
-			}
-		case "FAILED":
-			return &ComposeEntry{
-				ID:          id,
-				Blueprint:   compose.Blueprint.Name,
-				Version:     compose.Blueprint.Version,
-				ComposeType: compose.OutputType,
-				QueueStatus: compose.QueueStatus,
-				JobCreated:  float64(compose.JobCreated.UnixNano()) / 1000000000,
-				JobStarted:  float64(compose.JobStarted.UnixNano()) / 1000000000,
-				JobFinished: float64(compose.JobFinished.UnixNano()) / 1000000000,
-			}
-		default:
-			panic("invalid compose state")
-		}
-	}
+	composes := make(map[uuid.UUID]Compose)
 
-	var composes []*ComposeEntry
-	if uuids == nil {
-		composes = make([]*ComposeEntry, 0, len(s.Composes))
-		for id, compose := range s.Composes {
-			composes = append(composes, newCompose(id, compose))
-		}
-	} else {
-		composes = make([]*ComposeEntry, 0, len(uuids))
-		for _, id := range uuids {
-			if compose, exists := s.Composes[id]; exists {
-				composes = append(composes, newCompose(id, compose))
-			}
+	for id, compose := range s.Composes {
+		newCompose := compose
+		newCompose.Targets = []*target.Target{}
+
+		for _, t := range compose.Targets {
+			newTarget := *t
+			newCompose.Targets = append(newCompose.Targets, &newTarget)
 		}
+
+		newBlueprint := *compose.Blueprint
+		newCompose.Blueprint = &newBlueprint
+
+		composes[id] = newCompose
 	}
 
 	return composes
@@ -443,7 +379,7 @@ func (s *Store) DeleteBlueprintFromWorkspace(name string) {
 	})
 }
 
-func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, composeType string) error {
+func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, composeType string, uploadTarget *target.Target) error {
 	targets := []*target.Target{
 		target.NewLocalTarget(
 			&target.LocalTargetOptions{
@@ -451,6 +387,11 @@ func (s *Store) PushCompose(composeID uuid.UUID, bp *blueprint.Blueprint, compos
 			},
 		),
 	}
+
+	if uploadTarget != nil {
+		targets = append(targets, uploadTarget)
+	}
+
 	pipeline, err := s.distro.Pipeline(bp, composeType)
 	if err != nil {
 		return err
@@ -483,6 +424,9 @@ func (s *Store) PopCompose() Job {
 		}
 		compose.JobStarted = time.Now()
 		compose.QueueStatus = "RUNNING"
+		for _, t := range compose.Targets {
+			t.Status = "RUNNING"
+		}
 		s.Composes[job.ComposeID] = compose
 		return nil
 	})
@@ -513,6 +457,9 @@ func (s *Store) UpdateCompose(composeID uuid.UUID, status string) error {
 				return &NotRunningError{"compose was not running"}
 			}
 			compose.QueueStatus = status
+			for _, t := range compose.Targets {
+				t.Status = status
+			}
 			s.Composes[composeID] = compose
 		default:
 			return &InvalidRequestError{"invalid state transition"}
diff --git a/internal/target/aws_target.go b/internal/target/aws_target.go
index 5048c3aba..d54172afa 100644
--- a/internal/target/aws_target.go
+++ b/internal/target/aws_target.go
@@ -11,8 +11,5 @@ type AWSTargetOptions struct {
 func (AWSTargetOptions) isTargetOptions() {}
 
 func NewAWSTarget(options *AWSTargetOptions) *Target {
-	return &Target{
-		Name:    "org.osbuild.aws",
-		Options: options,
-	}
+	return newTarget("org.osbuild.aws", options)
 }
diff --git a/internal/target/azure_target.go b/internal/target/azure_target.go
index 10ad8434c..9b9a2e2fd 100644
--- a/internal/target/azure_target.go
+++ b/internal/target/azure_target.go
@@ -9,8 +9,5 @@ type AzureTargetOptions struct {
 func (AzureTargetOptions) isTargetOptions() {}
 
 func NewAzureTarget(options *AzureTargetOptions) *Target {
-	return &Target{
-		Name:    "org.osbuild.azure",
-		Options: options,
-	}
+	return newTarget("org.osbuild.azure", options)
 }
diff --git a/internal/target/local_target.go b/internal/target/local_target.go
index 816354bba..35209d524 100644
--- a/internal/target/local_target.go
+++ b/internal/target/local_target.go
@@ -7,8 +7,5 @@ type LocalTargetOptions struct {
 func (LocalTargetOptions) isTargetOptions() {}
 
 func NewLocalTarget(options *LocalTargetOptions) *Target {
-	return &Target{
-		Name:    "org.osbuild.local",
-		Options: options,
-	}
+	return newTarget("org.osbuild.local", options)
 }
diff --git a/internal/target/target.go b/internal/target/target.go
index f66d1f6fc..19d8abd52 100644
--- a/internal/target/target.go
+++ b/internal/target/target.go
@@ -3,21 +3,40 @@ package target
 import (
 	"encoding/json"
 	"errors"
+	"github.com/google/uuid"
+	"time"
 )
 
 type Target struct {
+	Uuid      uuid.UUID     `json:"uuid"`
 	ImageName string        `json:"image_name"`
 	Name      string        `json:"name"`
+	Created   time.Time     `json:"created"`
+	Status    string        `json:"status"`
 	Options   TargetOptions `json:"options"`
 }
 
+func newTarget(name string, options TargetOptions) *Target {
+	return &Target{
+		Uuid:    uuid.New(),
+		Name:    name,
+		Created: time.Now(),
+		Status:  "WAITING",
+		Options: options,
+	}
+}
+
 type TargetOptions interface {
 	isTargetOptions()
 }
 
 type rawTarget struct {
-	Name    string          `json:"name"`
-	Options json.RawMessage `json:"options"`
+	Uuid      uuid.UUID       `json:"uuid"`
+	ImageName string          `json:"image_name"`
+	Name      string          `json:"name"`
+	Created   time.Time       `json:"created"`
+	Status    string          `json:"status"`
+	Options   json.RawMessage `json:"options"`
 }
 
 func (target *Target) UnmarshalJSON(data []byte) error {
@@ -26,8 +45,24 @@ func (target *Target) UnmarshalJSON(data []byte) error {
 	if err != nil {
 		return err
 	}
+	options, err := UnmarshalTargetOptions(rawTarget.Name, rawTarget.Options)
+	if err != nil {
+		return err
+	}
+
+	target.Uuid = rawTarget.Uuid
+	target.ImageName = rawTarget.ImageName
+	target.Name = rawTarget.Name
+	target.Created = rawTarget.Created
+	target.Status = rawTarget.Status
+	target.Options = options
+
+	return nil
+}
+
+func UnmarshalTargetOptions(targetName string, rawOptions json.RawMessage) (TargetOptions, error) {
 	var options TargetOptions
-	switch rawTarget.Name {
+	switch targetName {
 	case "org.osbuild.azure":
 		options = new(AzureTargetOptions)
 	case "org.osbuild.aws":
@@ -35,15 +70,9 @@ func (target *Target) UnmarshalJSON(data []byte) error {
 	case "org.osbuild.local":
 		options = new(LocalTargetOptions)
 	default:
-		return errors.New("unexpected target name")
-	}
-	err = json.Unmarshal(rawTarget.Options, options)
-	if err != nil {
-		return err
+		return nil, errors.New("unexpected target name")
 	}
+	err := json.Unmarshal(rawOptions, options)
 
-	target.Name = rawTarget.Name
-	target.Options = options
-
-	return nil
+	return options, err
 }
diff --git a/internal/upload/azure/azure.go b/internal/upload/azure/azure.go
index eec8717ca..88f07ea9a 100644
--- a/internal/upload/azure/azure.go
+++ b/internal/upload/azure/azure.go
@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"context"
+	"crypto/md5"
 	"fmt"
 	"io"
 	"net/url"
@@ -13,6 +14,14 @@ import (
 	"github.com/Azure/azure-storage-blob-go/azblob"
 )
 
+type errorString struct {
+	s string
+}
+
+func (e *errorString) Error() string {
+	return e.s
+}
+
 // Credentials contains credentials to connect to your account
 // It uses so called "Client credentials", see the official documentation for more information:
 // https://docs.microsoft.com/en-us/azure/go/azure-sdk-go-authorization#available-authentication-types-and-methods
@@ -55,6 +64,7 @@ func UploadImage(credentials Credentials, metadata ImageMetadata, fileName strin
 	if err != nil {
 		return err
 	}
+	defer imageFile.Close()
 
 	// Stat image to get the file size
 	stat, err := imageFile.Stat()
@@ -62,12 +72,27 @@ func UploadImage(credentials Credentials, metadata ImageMetadata, fileName strin
 		return err
 	}
 
+	// Hash the imageFile
+	imageFileHash := md5.New()
+	if _, err := io.Copy(imageFileHash, imageFile); err != nil {
+		return err
+	}
+	// Move the cursor back to the start of the imageFile
+	if _, err := imageFile.Seek(0, 0); err != nil {
+		return err
+	}
+
 	// Create page blob URL. Page blob is required for VM images
 	blobURL := containerURL.NewPageBlobURL(metadata.ImageName)
 	_, err = blobURL.Create(ctx, stat.Size(), 0, azblob.BlobHTTPHeaders{}, azblob.Metadata{}, azblob.BlobAccessConditions{})
 	if err != nil {
 		return err
 	}
+	// Wrong MD5 does not seem to have any impact on the upload
+	_, err = blobURL.SetHTTPHeaders(ctx, azblob.BlobHTTPHeaders{ContentMD5: imageFileHash.Sum(nil)}, azblob.BlobAccessConditions{})
+	if err != nil {
+		return err
+	}
 
 	// Create control variables
 	// This channel simulates behavior of a semaphore and bounds the number of parallel threads
@@ -110,11 +135,25 @@ func UploadImage(credentials Credentials, metadata ImageMetadata, fileName strin
 		}(counter, buffer, n)
 		counter++
 	}
+	// Wait for all gorutines to finish
 	wg.Wait()
+	// Check any errors during the transmission using a nonblocking read from the channel
 	select {
 	case err := <-errorInGorutine:
 		return err
 	default:
-		return nil
 	}
+	// Check properties, specifically MD5 sum of the blob
+	props, err := blobURL.GetProperties(ctx, azblob.BlobAccessConditions{})
+	if err != nil {
+		return err
+	}
+	var blobChecksum []byte = props.ContentMD5()
+	var fileChecksum []byte = imageFileHash.Sum(nil)
+
+	if bytes.Compare(blobChecksum, fileChecksum) != 0 {
+		return &errorString{"error during image upload. the image seems to be corrupted"}
+	}
+
+	return nil
 }
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 1a9b84865..b31a631f7 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -15,10 +15,10 @@ import (
 	"github.com/julienschmidt/httprouter"
 
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
-
-	"github.com/osbuild/osbuild-composer/internal/distro"
+	"github.com/osbuild/osbuild-composer/internal/target"
 )
 
 type API struct {
@@ -137,6 +137,18 @@ func verifyRequestVersion(writer http.ResponseWriter, params httprouter.Params,
 	return true
 }
 
+func isRequestVersionAtLeast(params httprouter.Params, minVersion uint) bool {
+	versionString := params.ByName("version")
+
+	version, err := strconv.ParseUint(versionString, 10, 0)
+
+	if err != nil {
+		panic(err)
+	}
+
+	return uint(version) >= minVersion
+}
+
 func methodNotAllowedHandler(writer http.ResponseWriter, request *http.Request) {
 	writer.WriteHeader(http.StatusMethodNotAllowed)
 }
@@ -1110,15 +1122,16 @@ func (api *API) blueprintDeleteWorkspaceHandler(writer http.ResponseWriter, requ
 // Schedule new compose by first translating the appropriate blueprint into a pipeline and then
 // pushing it into the channel for waiting builds.
 func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 
 	// https://weldr.io/lorax/pylorax.api.html#pylorax.api.v0.v0_compose_start
 	type ComposeRequest struct {
-		BlueprintName string `json:"blueprint_name"`
-		ComposeType   string `json:"compose_type"`
-		Branch        string `json:"branch"`
+		BlueprintName string         `json:"blueprint_name"`
+		ComposeType   string         `json:"compose_type"`
+		Branch        string         `json:"branch"`
+		Upload        *UploadRequest `json:"upload"`
 	}
 	type ComposeReply struct {
 		BuildID uuid.UUID `json:"build_id"`
@@ -1152,12 +1165,27 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		Status:  true,
 	}
 
+	var uploadTarget *target.Target
+	if isRequestVersionAtLeast(params, 1) && cr.Upload != nil {
+		uploadTarget, err = UploadRequestToTarget(*cr.Upload)
+
+		if err != nil {
+			errors := responseError{
+				ID:  "UploadError",
+				Msg: fmt.Sprintf("bad input format: %s", err.Error()),
+			}
+
+			statusResponseError(writer, http.StatusBadRequest, errors)
+			return
+		}
+	}
+
 	bp := blueprint.Blueprint{}
 	changed := false
 	found := api.store.GetBlueprint(cr.BlueprintName, &bp, &changed) // TODO: what to do with changed?
 
 	if found {
-		err := api.store.PushCompose(reply.BuildID, &bp, cr.ComposeType)
+		err := api.store.PushCompose(reply.BuildID, &bp, cr.ComposeType, uploadTarget)
 
 		// TODO: we should probably do some kind of blueprint validation in future
 		// for now, let's just 500 and bail out
@@ -1183,7 +1211,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 }
 
 func (api *API) composeTypesHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 	type composeType struct {
@@ -1203,24 +1231,22 @@ func (api *API) composeTypesHandler(writer http.ResponseWriter, request *http.Re
 }
 
 func (api *API) composeQueueHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 
 	var reply struct {
-		New []*store.ComposeEntry `json:"new"`
-		Run []*store.ComposeEntry `json:"run"`
+		New []*ComposeEntry `json:"new"`
+		Run []*ComposeEntry `json:"run"`
 	}
 
-	reply.New = make([]*store.ComposeEntry, 0)
-	reply.Run = make([]*store.ComposeEntry, 0)
-
-	for _, entry := range api.store.ListQueue(nil) {
-		switch entry.QueueStatus {
+	composes := api.store.GetAllComposes()
+	for id, compose := range composes {
+		switch compose.QueueStatus {
 		case "WAITING":
-			reply.New = append(reply.New, entry)
+			reply.New = append(reply.New, composeToComposeEntry(id, compose, isRequestVersionAtLeast(params, 1)))
 		case "RUNNING":
-			reply.Run = append(reply.Run, entry)
+			reply.Run = append(reply.Run, composeToComposeEntry(id, compose, isRequestVersionAtLeast(params, 1)))
 		}
 	}
 
@@ -1228,29 +1254,38 @@ func (api *API) composeQueueHandler(writer http.ResponseWriter, request *http.Re
 }
 
 func (api *API) composeStatusHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	// TODO: lorax has some params: /api/v0/compose/status/<uuids>[?blueprint=<blueprint_name>&status=<compose_status>&type=<compose_type>]
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 
 	var reply struct {
-		UUIDs []*store.ComposeEntry `json:"uuids"`
+		UUIDs []*ComposeEntry `json:"uuids"`
 	}
 
-	uuidStrings := strings.Split(params.ByName("uuids"), ",")
-	uuids := make([]uuid.UUID, len(uuidStrings))
-	for _, uuidString := range uuidStrings {
-		id, err := uuid.Parse(uuidString)
-		if err != nil {
-			errors := responseError{
-				ID:  "UnknownUUID",
-				Msg: fmt.Sprintf("%s is not a valid build uuid", uuidString),
+	uuidsParam := params.ByName("uuids")
+
+	var uuids []uuid.UUID
+
+	if uuidsParam != "*" {
+		uuidStrings := strings.Split(uuidsParam, ",")
+		uuids = make([]uuid.UUID, len(uuidStrings))
+		for _, uuidString := range uuidStrings {
+			id, err := uuid.Parse(uuidString)
+			if err != nil {
+				errors := responseError{
+					ID:  "UnknownUUID",
+					Msg: fmt.Sprintf("%s is not a valid build uuid", uuidString),
+				}
+				statusResponseError(writer, http.StatusBadRequest, errors)
+				return
 			}
-			statusResponseError(writer, http.StatusBadRequest, errors)
-			return
+			uuids = append(uuids, id)
 		}
-		uuids = append(uuids, id)
 	}
-	reply.UUIDs = api.store.ListQueue(uuids)
+	composes := api.store.GetAllComposes()
+
+	reply.UUIDs = composesToComposeEntries(composes, uuids, isRequestVersionAtLeast(params, 1))
 
 	json.NewEncoder(writer).Encode(reply)
 }
@@ -1298,29 +1333,41 @@ func (api *API) composeImageHandler(writer http.ResponseWriter, request *http.Re
 }
 
 func (api *API) composeFinishedHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 
 	var reply struct {
-		Finished []interface{} `json:"finished"`
+		Finished []*ComposeEntry `json:"finished"`
 	}
 
-	reply.Finished = make([]interface{}, 0)
+	composes := api.store.GetAllComposes()
+	for _, entry := range composesToComposeEntries(composes, nil, isRequestVersionAtLeast(params, 1)) {
+		switch entry.QueueStatus {
+		case "FINISHED":
+			reply.Finished = append(reply.Finished, entry)
+		}
+	}
 
 	json.NewEncoder(writer).Encode(reply)
 }
 
 func (api *API) composeFailedHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
-	if !verifyRequestVersion(writer, params, 0) { // TODO: version 1 API
+	if !verifyRequestVersion(writer, params, 0) {
 		return
 	}
 
 	var reply struct {
-		Failed []interface{} `json:"failed"`
+		Failed []*ComposeEntry `json:"failed"`
 	}
 
-	reply.Failed = make([]interface{}, 0)
+	composes := api.store.GetAllComposes()
+	for _, entry := range composesToComposeEntries(composes, nil, isRequestVersionAtLeast(params, 1)) {
+		switch entry.QueueStatus {
+		case "FAILED":
+			reply.Failed = append(reply.Failed, entry)
+		}
+	}
 
 	json.NewEncoder(writer).Encode(reply)
 }
diff --git a/internal/weldr/compose.go b/internal/weldr/compose.go
new file mode 100644
index 000000000..80c2ccdd8
--- /dev/null
+++ b/internal/weldr/compose.go
@@ -0,0 +1,88 @@
+package weldr
+
+import (
+	"github.com/google/uuid"
+	"github.com/osbuild/osbuild-composer/internal/store"
+	"sort"
+)
+
+type ComposeEntry struct {
+	ID          uuid.UUID        `json:"id"`
+	Blueprint   string           `json:"blueprint"`
+	Version     string           `json:"version"`
+	ComposeType string           `json:"compose_type"`
+	ImageSize   int64            `json:"image_size"`
+	QueueStatus string           `json:"queue_status"`
+	JobCreated  float64          `json:"job_created"`
+	JobStarted  float64          `json:"job_started,omitempty"`
+	JobFinished float64          `json:"job_finished,omitempty"`
+	Uploads     []UploadResponse `json:"uploads,omitempty"`
+}
+
+func composeToComposeEntry(id uuid.UUID, compose store.Compose, includeUploads bool) *ComposeEntry {
+	var composeEntry ComposeEntry
+
+	composeEntry.ID = id
+	composeEntry.Blueprint = compose.Blueprint.Name
+	composeEntry.Version = compose.Blueprint.Version
+	composeEntry.ComposeType = compose.OutputType
+	composeEntry.QueueStatus = compose.QueueStatus
+
+	if includeUploads {
+		composeEntry.Uploads = TargetsToUploadResponses(compose.Targets)
+	}
+
+	switch compose.QueueStatus {
+	case "WAITING":
+		composeEntry.JobCreated = float64(compose.JobCreated.UnixNano()) / 1000000000
+
+	case "RUNNING":
+		composeEntry.JobCreated = float64(compose.JobCreated.UnixNano()) / 1000000000
+		composeEntry.JobStarted = float64(compose.JobStarted.UnixNano()) / 1000000000
+
+	case "FINISHED":
+		//image, err := s.GetImage(id)
+		//imageSize := int64(0)
+		//if err == nil {
+		//	imageSize = image.Size
+		//}
+		// TODO: this is currently broken!
+		composeEntry.ImageSize = int64(0)
+		composeEntry.JobCreated = float64(compose.JobCreated.UnixNano()) / 1000000000
+		composeEntry.JobStarted = float64(compose.JobStarted.UnixNano()) / 1000000000
+		composeEntry.JobFinished = float64(compose.JobFinished.UnixNano()) / 1000000000
+
+	case "FAILED":
+		composeEntry.JobCreated = float64(compose.JobCreated.UnixNano()) / 1000000000
+		composeEntry.JobStarted = float64(compose.JobStarted.UnixNano()) / 1000000000
+		composeEntry.JobFinished = float64(compose.JobFinished.UnixNano()) / 1000000000
+	default:
+		panic("invalid compose state")
+	}
+
+	return &composeEntry
+}
+
+func composesToComposeEntries(composes map[uuid.UUID]store.Compose, uuids []uuid.UUID, includeUploads bool) []*ComposeEntry {
+	var composeEntries []*ComposeEntry
+	if uuids == nil {
+		composeEntries = make([]*ComposeEntry, 0, len(composes))
+		for id, compose := range composes {
+			composeEntries = append(composeEntries, composeToComposeEntry(id, compose, includeUploads))
+		}
+	} else {
+		composeEntries = make([]*ComposeEntry, 0, len(uuids))
+		for _, id := range uuids {
+			if compose, exists := composes[id]; exists {
+				composeEntries = append(composeEntries, composeToComposeEntry(id, compose, includeUploads))
+			}
+		}
+	}
+
+	// make this function output more predictable
+	sort.Slice(composeEntries, func(i, j int) bool {
+		return composeEntries[i].ID.String() < composeEntries[j].ID.String()
+	})
+
+	return composeEntries
+}
diff --git a/internal/weldr/upload.go b/internal/weldr/upload.go
new file mode 100644
index 000000000..44ef52457
--- /dev/null
+++ b/internal/weldr/upload.go
@@ -0,0 +1,110 @@
+package weldr
+
+import (
+	"encoding/json"
+	"errors"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/osbuild/osbuild-composer/internal/target"
+)
+
+type UploadResponse struct {
+	Uuid         uuid.UUID            `json:"uuid"`
+	Status       string               `json:"status"`
+	ProviderName string               `json:"provider_name"`
+	ImageName    string               `json:"image_name"`
+	CreationTime float64              `json:"creation_time"`
+	Settings     target.TargetOptions `json:"settings"`
+}
+
+type UploadRequest struct {
+	Provider  string               `json:"provider"`
+	ImageName string               `json:"image_name"`
+	Settings  target.TargetOptions `json:"settings"`
+}
+
+type rawUploadRequest struct {
+	Provider  string          `json:"provider"`
+	ImageName string          `json:"image_name"`
+	Settings  json.RawMessage `json:"settings"`
+}
+
+func (u *UploadRequest) UnmarshalJSON(data []byte) error {
+	var rawUpload rawUploadRequest
+	err := json.Unmarshal(data, &rawUpload)
+	if err != nil {
+		return err
+	}
+
+	// we need to convert provider name to target name to use the unmarshaller
+	targetName := providerNameToTargetNameMap[rawUpload.Provider]
+	options, err := target.UnmarshalTargetOptions(targetName, rawUpload.Settings)
+
+	u.Provider = rawUpload.Provider
+	u.ImageName = rawUpload.ImageName
+	u.Settings = options
+
+	return err
+}
+
+var targetNameToProviderNameMap = map[string]string{
+	"org.osbuild.aws":   "aws",
+	"org.osbuild.azure": "azure",
+}
+
+var providerNameToTargetNameMap = map[string]string{
+	"aws":   "org.osbuild.aws",
+	"azure": "org.osbuild.azure",
+}
+
+func targetToUploadResponse(t *target.Target) UploadResponse {
+	var u UploadResponse
+
+	providerName, providerExist := targetNameToProviderNameMap[t.Name]
+	if !providerExist {
+		panic("target name " + t.Name + " is not defined in conversion map!")
+	}
+
+	u.CreationTime = float64(t.Created.UnixNano()) / 1000000000
+	u.ImageName = t.ImageName
+	u.ProviderName = providerName
+	u.Status = t.Status
+	u.Uuid = t.Uuid
+	u.Settings = t.Options
+
+	return u
+}
+
+func TargetsToUploadResponses(targets []*target.Target) []UploadResponse {
+	var uploads []UploadResponse
+	for _, t := range targets {
+		if t.Name == "org.osbuild.local" {
+			continue
+		}
+
+		upload := targetToUploadResponse(t)
+
+		uploads = append(uploads, upload)
+	}
+
+	return uploads
+}
+
+func UploadRequestToTarget(u UploadRequest) (*target.Target, error) {
+	var t target.Target
+	targetName, targetExist := providerNameToTargetNameMap[u.Provider]
+
+	if !targetExist {
+		return nil, errors.New("Unknown provider name " + u.Provider)
+	}
+
+	t.Uuid = uuid.New()
+	t.ImageName = u.ImageName
+	t.Options = u.Settings
+	t.Name = targetName
+	t.Status = "WAITING"
+	t.Created = time.Now()
+
+	return &t, nil
+}
diff --git a/osbuild b/osbuild
index 64713449c..d8bb359e8 160000
--- a/osbuild
+++ b/osbuild
@@ -1,1 +1,1 @@
-Subproject commit 64713449cef127fe0ae20675d2cec7acbc6caaa4
+Subproject commit d8bb359e8184d960d618dd56b9489a06ec574126


Test patch:
diff --git a/internal/distro/fedora30/distro_test.go b/internal/distro/fedora30/distro_test.go
index 7ed693861..d4af43106 100644
--- a/internal/distro/fedora30/distro_test.go
+++ b/internal/distro/fedora30/distro_test.go
@@ -39,7 +39,7 @@ func TestFilenameFromType(t *testing.T) {
 		{
 			name:  "ami",
 			args:  args{"ami"},
-			want:  "image.ami",
+			want:  "image.raw.xz",
 			want1: "application/octet-stream",
 		},
 		{
diff --git a/internal/jobqueue/api_test.go b/internal/jobqueue/api_test.go
index b74f767e0..34bdf1daf 100644
--- a/internal/jobqueue/api_test.go
+++ b/internal/jobqueue/api_test.go
@@ -43,13 +43,13 @@ func TestCreate(t *testing.T) {
 	store := store.New(nil, distro.New("fedora-30"))
 	api := jobqueue.New(nil, store)
 
-	err := store.PushCompose(id, &blueprint.Blueprint{}, "tar")
+	err := store.PushCompose(id, &blueprint.Blueprint{}, "tar", nil)
 	if err != nil {
 		t.Fatalf("error pushing compose: %v", err)
 	}
 
 	test.TestRoute(t, api, false, "POST", "/job-queue/v1/jobs", `{}`, http.StatusCreated,
-		`{"id":"ffffffff-ffff-ffff-ffff-ffffffffffff","pipeline":{"build":{"pipeline":{"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["dnf","e2fsprogs","policycoreutils","qemu-img","systemd","grub2-pc","tar"],"releasever":"30","basearch":"x86_64"}}]},"runner":"org.osbuild.fedora30"},"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["policycoreutils","selinux-policy-targeted","kernel","firewalld","chrony","langpacks-en"],"exclude_packages":["dracut-config-rescue"],"releasever":"30","basearch":"x86_64"}},{"name":"org.osbuild.fix-bls","options":{}},{"name":"org.osbuild.locale","options":{"language":"en_US"}},{"name":"org.osbuild.grub2","options":{"root_fs_uuid":"76a22bf4-f153-4541-b6c7-0332c0dfaeac","boot_fs_uuid":"00000000-0000-0000-0000-000000000000","kernel_opts":"ro biosdevname=0 net.ifnames=0"}},{"name":"org.osbuild.selinux","options":{"file_contexts":"etc/selinux/targeted/contexts/files/file_contexts"}}],"assembler":{"name":"org.osbuild.tar","options":{"filename":"root.tar.xz"}}},"targets":[{"image_name":"","name":"org.osbuild.local","options":{"location":"/var/lib/osbuild-composer/outputs/ffffffff-ffff-ffff-ffff-ffffffffffff"}}]}`)
+		`{"id":"ffffffff-ffff-ffff-ffff-ffffffffffff","pipeline":{"build":{"pipeline":{"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["dnf","e2fsprogs","policycoreutils","qemu-img","systemd","grub2-pc","tar"],"releasever":"30","basearch":"x86_64"}}]},"runner":"org.osbuild.fedora30"},"stages":[{"name":"org.osbuild.dnf","options":{"repos":[{"metalink":"https://mirrors.fedoraproject.org/metalink?repo=fedora-$releasever\u0026arch=$basearch","gpgkey":"-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nmQINBFturGcBEACv0xBo91V2n0uEC2vh69ywCiSyvUgN/AQH8EZpCVtM7NyjKgKm\nbbY4G3R0M3ir1xXmvUDvK0493/qOiFrjkplvzXFTGpPTi0ypqGgxc5d0ohRA1M75\nL+0AIlXoOgHQ358/c4uO8X0JAA1NYxCkAW1KSJgFJ3RjukrfqSHWthS1d4o8fhHy\nKJKEnirE5hHqB50dafXrBfgZdaOs3C6ppRIePFe2o4vUEapMTCHFw0woQR8Ah4/R\nn7Z9G9Ln+0Cinmy0nbIDiZJ+pgLAXCOWBfDUzcOjDGKvcpoZharA07c0q1/5ojzO\n4F0Fh4g/BUmtrASwHfcIbjHyCSr1j/3Iz883iy07gJY5Yhiuaqmp0o0f9fgHkG53\n2xCU1owmACqaIBNQMukvXRDtB2GJMuKa/asTZDP6R5re+iXs7+s9ohcRRAKGyAyc\nYKIQKcaA+6M8T7/G+TPHZX6HJWqJJiYB+EC2ERblpvq9TPlLguEWcmvjbVc31nyq\nSDoO3ncFWKFmVsbQPTbP+pKUmlLfJwtb5XqxNR5GEXSwVv4I7IqBmJz1MmRafnBZ\ng0FJUtH668GnldO20XbnSVBr820F5SISMXVwCXDXEvGwwiB8Lt8PvqzXnGIFDAu3\nDlQI5sxSqpPVWSyw08ppKT2Tpmy8adiBotLfaCFl2VTHwOae48X2dMPBvQARAQAB\ntDFGZWRvcmEgKDMwKSA8ZmVkb3JhLTMwLXByaW1hcnlAZmVkb3JhcHJvamVjdC5v\ncmc+iQI4BBMBAgAiBQJbbqxnAhsPBgsJCAcDAgYVCAIJCgsEFgIDAQIeAQIXgAAK\nCRDvPBEfz8ZZudTnD/9170LL3nyTVUCFmBjT9wZ4gYnpwtKVPa/pKnxbbS+Bmmac\ng9TrT9pZbqOHrNJLiZ3Zx1Hp+8uxr3Lo6kbYwImLhkOEDrf4aP17HfQ6VYFbQZI8\nf79OFxWJ7si9+3gfzeh9UYFEqOQfzIjLWFyfnas0OnV/P+RMQ1Zr+vPRqO7AR2va\nN9wg+Xl7157dhXPCGYnGMNSoxCbpRs0JNlzvJMuAea5nTTznRaJZtK/xKsqLn51D\nK07k9MHVFXakOH8QtMCUglbwfTfIpO5YRq5imxlWbqsYWVQy1WGJFyW6hWC0+RcJ\nOx5zGtOfi4/dN+xJ+ibnbyvy/il7Qm+vyFhCYqIPyS5m2UVJUuao3eApE38k78/o\n8aQOTnFQZ+U1Sw+6woFTxjqRQBXlQm2+7Bt3bqGATg4sXXWPbmwdL87Ic+mxn/ml\nSMfQux/5k6iAu1kQhwkO2YJn9eII6HIPkW+2m5N1JsUyJQe4cbtZE5Yh3TRA0dm7\n+zoBRfCXkOW4krchbgww/ptVmzMMP7GINJdROrJnsGl5FVeid9qHzV7aZycWSma7\nCxBYB1J8HCbty5NjtD6XMYRrMLxXugvX6Q4NPPH+2NKjzX4SIDejS6JjgrP3KA3O\npMuo7ZHMfveBngv8yP+ZD/1sS6l+dfExvdaJdOdgFCnp4p3gPbw5+Lv70HrMjA==\n=BfZ/\n-----END PGP PUBLIC KEY BLOCK-----\n","checksum":"sha256:9f596e18f585bee30ac41c11fb11a83ed6b11d5b341c1cb56ca4015d7717cb97"}],"packages":["policycoreutils","selinux-policy-targeted","kernel","firewalld","chrony","langpacks-en"],"exclude_packages":["dracut-config-rescue"],"releasever":"30","basearch":"x86_64"}},{"name":"org.osbuild.fix-bls","options":{}},{"name":"org.osbuild.locale","options":{"language":"en_US"}},{"name":"org.osbuild.grub2","options":{"root_fs_uuid":"76a22bf4-f153-4541-b6c7-0332c0dfaeac","boot_fs_uuid":"00000000-0000-0000-0000-000000000000","kernel_opts":"ro biosdevname=0 net.ifnames=0"}},{"name":"org.osbuild.selinux","options":{"file_contexts":"etc/selinux/targeted/contexts/files/file_contexts"}}],"assembler":{"name":"org.osbuild.tar","options":{"filename":"root.tar.xz"}}},"targets":[{"image_name":"","name":"org.osbuild.local","options":{"location":"/var/lib/osbuild-composer/outputs/ffffffff-ffff-ffff-ffff-ffffffffffff"},"status":"RUNNING"}]}`, "created", "uuid")
 }
 
 func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
@@ -58,7 +58,7 @@ func testUpdateTransition(t *testing.T, from, to string, expectedStatus int) {
 	api := jobqueue.New(nil, store)
 
 	if from != "VOID" {
-		err := store.PushCompose(id, &blueprint.Blueprint{}, "tar")
+		err := store.PushCompose(id, &blueprint.Blueprint{}, "tar", nil)
 		if err != nil {
 			t.Fatalf("error pushing compose: %v", err)
 		}
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
index 6cd77f153..3aa0db197 100644
--- a/internal/test/helpers.go
+++ b/internal/test/helpers.go
@@ -9,9 +9,12 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"os"
-	"reflect"
 	"strings"
 	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/uuid"
 )
 
 type API interface {
@@ -137,8 +140,20 @@ func TestRoute(t *testing.T, api API, external bool, method, path, body string,
 	dropFields(reply, ignoreFields...)
 	dropFields(expected, ignoreFields...)
 
-	if !reflect.DeepEqual(reply, expected) {
-		t.Errorf("%s: reply != expected:\n   reply: %s\nexpected: %s", path, strings.TrimSpace(string(replyJSON)), expectedJSON)
+	if diff := cmp.Diff(expected, reply); diff != "" {
+		t.Errorf("%s: reply != expected:\n   reply: %s\nexpected: %s\ndiff: %s", path, strings.TrimSpace(string(replyJSON)), expectedJSON, diff)
 		return
 	}
 }
+
+func IgnoreDates() cmp.Option {
+	return cmp.Comparer(func(a, b time.Time) bool { return true })
+}
+
+func IgnoreUuids() cmp.Option {
+	return cmp.Comparer(func(a, b uuid.UUID) bool { return true })
+}
+
+func Ignore(what string) cmp.Option {
+	return cmp.FilterPath(func(p cmp.Path) bool { return p.String() == what }, cmp.Ignore())
+}
diff --git a/internal/upload/azure/azure_test.go b/internal/upload/azure/azure_test.go
new file mode 100644
index 000000000..79b4bf2d6
--- /dev/null
+++ b/internal/upload/azure/azure_test.go
@@ -0,0 +1,117 @@
+package azure
+
+import (
+	"bytes"
+	"context"
+	"crypto/md5"
+	"fmt"
+	"io"
+	"net/url"
+	"os"
+	"os/exec"
+	"path"
+	"testing"
+
+	"github.com/Azure/azure-storage-blob-go/azblob"
+)
+
+func handleErrors(t *testing.T, err error) {
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func loadEnvVar(t *testing.T, envVarName string) (string, bool) {
+	variable, exists := os.LookupEnv(envVarName)
+	if !exists {
+		t.Logf("Environment variable does not exist: %s", envVarName)
+		return "", false
+	}
+	return variable, true
+}
+
+func TestAzure_FileUpload(t *testing.T) {
+	// Load configuration
+	storageAccount, saExists := loadEnvVar(t, "AZURE_STORAGE_ACCOUNT")
+	storageAccessKey, sakExists := loadEnvVar(t, "AZURE_STORAGE_ACCESS_KEY")
+	containerName, cnExists := loadEnvVar(t, "AZURE_STORAGE_CONTAINER")
+	fileName := "/tmp/testing-image.vhd"
+	var threads int = 4
+
+	// Workaround Travis security feature. If non of the variables is set, just ignore the test
+	if saExists == false && sakExists == false && cnExists == false {
+		t.Log("No AZURE configuration provided, assuming that this is running in CI. Skipping the test.")
+		return
+	}
+	// If only one/two of them are not set, then fail
+	if saExists == false || sakExists == false || cnExists == false {
+		t.Fatal("You need to define all variables for AZURE connection.")
+	}
+
+	// Prepare the file
+	cmd := exec.Command("dd", "bs=512", "count=512", "if=/dev/urandom", fmt.Sprintf("of=%s", fileName))
+	err := cmd.Run()
+	handleErrors(t, err)
+	t.Log("Image to upload is:", fileName)
+
+	// Calculate MD5 sum of the file
+	f, err := os.Open(fileName)
+	handleErrors(t, err)
+
+	h := md5.New()
+	io.Copy(h, f)
+	handleErrors(t, err)
+	imageChecksum := h.Sum(nil)
+	f.Close()
+
+	credentials := Credentials{
+		StorageAccount:   storageAccount,
+		StorageAccessKey: storageAccessKey,
+	}
+	metadata := ImageMetadata{
+		ImageName:     path.Base(fileName),
+		ContainerName: containerName,
+	}
+	// Upload the image
+	err = UploadImage(credentials, metadata, fileName, threads)
+	handleErrors(t, err)
+
+	// Download the image
+	// Create a default request pipeline using your storage account name and account key.
+	credential, err := azblob.NewSharedKeyCredential(credentials.StorageAccount, credentials.StorageAccessKey)
+	handleErrors(t, err)
+
+	p := azblob.NewPipeline(credential, azblob.PipelineOptions{})
+
+	// get storage account blob service URL endpoint.
+	URL, _ := url.Parse(fmt.Sprintf("https://%s.blob.core.windows.net/%s", credentials.StorageAccount, metadata.ContainerName))
+
+	// Create a ContainerURL object that wraps the container URL and a request
+	// pipeline to make requests.
+	containerURL := azblob.NewContainerURL(*URL, p)
+
+	// Create the container, use a never-expiring context
+	ctx := context.Background()
+
+	blobURL := containerURL.NewPageBlobURL(metadata.ImageName)
+
+	get, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false)
+	handleErrors(t, err)
+	blobData := &bytes.Buffer{}
+	reader := get.Body(azblob.RetryReaderOptions{})
+	blobData.ReadFrom(reader)
+	reader.Close() // The client must close the response body when finished with it
+	blobBytes := blobData.Bytes()
+	blobChecksum := md5.Sum(blobBytes)
+	t.Logf("Local image checksum:      %x\n", imageChecksum)
+	t.Logf("Downloaded blob checksum : %x\n", blobChecksum)
+
+	// Delete the blob and throw away any errors
+	_, _ = blobURL.Delete(ctx, azblob.DeleteSnapshotsOptionInclude, azblob.BlobAccessConditions{})
+	_ = os.Remove(fileName)
+
+	if bytes.Compare(imageChecksum, blobChecksum[:]) != 0 {
+		t.Fatalf("Checksums do not match! Local file: %x, cloud blob: %x", imageChecksum, blobChecksum[:])
+	}
+
+}
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index d6179b874..a3b1f75b2 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -8,20 +8,24 @@ import (
 	"testing"
 	"time"
 
+	"github.com/osbuild/osbuild-composer/internal/blueprint"
+	"github.com/osbuild/osbuild-composer/internal/distro"
+	_ "github.com/osbuild/osbuild-composer/internal/distro/test"
 	rpmmd_mock "github.com/osbuild/osbuild-composer/internal/mocks/rpmmd"
+	"github.com/osbuild/osbuild-composer/internal/store"
+	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/test"
 	"github.com/osbuild/osbuild-composer/internal/weldr"
 
-	"github.com/osbuild/osbuild-composer/internal/distro"
-	_ "github.com/osbuild/osbuild-composer/internal/distro/test"
+	"github.com/google/go-cmp/cmp"
 )
 
-func createWeldrAPI(fixtureGenerator rpmmd_mock.FixtureGenerator) *weldr.API {
+func createWeldrAPI(fixtureGenerator rpmmd_mock.FixtureGenerator) (*weldr.API, *store.Store) {
 	fixture := fixtureGenerator()
 	rpm := rpmmd_mock.NewRPMMDMock(fixture)
 	d := distro.New("test")
 
-	return weldr.New(rpm, d, nil, fixture.Store)
+	return weldr.New(rpm, d, nil, fixture.Store), fixture.Store
 }
 
 func TestBasic(t *testing.T) {
@@ -46,7 +50,7 @@ func TestBasic(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -63,7 +67,7 @@ func TestBlueprintsNew(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -80,7 +84,7 @@ func TestBlueprintsWorkspace(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
 	}
@@ -101,7 +105,7 @@ func TestBlueprintsInfo(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test1","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test2","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/workspace", `{"name":"test2","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`)
@@ -122,7 +126,7 @@ func TestBlueprintsFreeze(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.SendHTTP(api, false, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"dep-package1","version":"*"}],"version":"0.0.0"}`)
 		test.TestRoute(t, api, false, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 		test.SendHTTP(api, false, "DELETE", "/api/v0/blueprints/delete/test", ``)
@@ -141,7 +145,7 @@ func TestBlueprintsDiff(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/workspace", `{"name":"test","description":"Test","packages":[{"name":"systemd","version":"123"}],"version":"0.0.0"}`)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
@@ -161,7 +165,7 @@ func TestBlueprintsDelete(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.SendHTTP(api, true, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
 		test.SendHTTP(api, true, "DELETE", "/api/v0/blueprints/delete/test", ``)
@@ -169,7 +173,7 @@ func TestBlueprintsDelete(t *testing.T) {
 }
 
 func TestBlueprintsChanges(t *testing.T) {
-	api := createWeldrAPI(rpmmd_mock.BaseFixture)
+	api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 	rand.Seed(time.Now().UnixNano())
 	id := strconv.Itoa(rand.Int())
 	ignoreFields := []string{"commit", "timestamp"}
@@ -184,24 +188,121 @@ func TestBlueprintsChanges(t *testing.T) {
 }
 
 func TestCompose(t *testing.T) {
+	expectedComposeLocal := &store.Compose{
+		QueueStatus: "WAITING",
+		Blueprint: &blueprint.Blueprint{
+			Name:           "test",
+			Version:        "0.0.0",
+			Packages:       []blueprint.Package{},
+			Modules:        []blueprint.Package{},
+			Groups:         []blueprint.Group{},
+			Customizations: nil,
+		},
+		OutputType: "tar",
+		Targets: []*target.Target{
+			{
+				Name:    "org.osbuild.local",
+				Created: time.Time{},
+				Status:  "WAITING",
+				Options: &target.LocalTargetOptions{},
+			},
+		},
+	}
+
+	expectedComposeLocalAndAws := &store.Compose{
+		QueueStatus: "WAITING",
+		Blueprint: &blueprint.Blueprint{
+			Name:           "test",
+			Version:        "0.0.0",
+			Packages:       []blueprint.Package{},
+			Modules:        []blueprint.Package{},
+			Groups:         []blueprint.Group{},
+			Customizations: nil,
+		},
+		OutputType: "tar",
+		Targets: []*target.Target{
+			{
+				Name:    "org.osbuild.local",
+				Status:  "WAITING",
+				Options: &target.LocalTargetOptions{},
+			},
+			{
+				Name:      "org.osbuild.aws",
+				Status:    "WAITING",
+				ImageName: "test_upload",
+				Options: &target.AWSTargetOptions{
+					Region:          "frankfurt",
+					AccessKeyID:     "accesskey",
+					SecretAccessKey: "secretkey",
+					Bucket:          "clay",
+					Key:             "imagekey",
+				},
+			},
+		},
+	}
+
 	var cases = []struct {
-		External       bool
+		External        bool
+		Method          string
+		Path            string
+		Body            string
+		ExpectedStatus  int
+		ExpectedJSON    string
+		ExpectedCompose *store.Compose
+		IgnoreFields    []string
+	}{
+		{true, "POST", "/api/v0/compose", `{"blueprint_name": "http-server","compose_type": "tar","branch": "master"}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}]}`, nil, []string{"build_id"}},
+		{false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`, http.StatusOK, `{"status": true}`, expectedComposeLocal, []string{"build_id"}},
+		{false, "POST", "/api/v1/compose", `{"blueprint_name": "test","compose_type":"tar","branch":"master","upload":{"image_name":"test_upload","provider":"aws","settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}}`, http.StatusOK, `{"status": true}`, expectedComposeLocalAndAws, []string{"build_id"}},
+	}
+
+	for _, c := range cases {
+		api, s := createWeldrAPI(rpmmd_mock.NoComposesFixture)
+		test.TestRoute(t, api, c.External, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, c.IgnoreFields...)
+
+		if c.ExpectedStatus != http.StatusOK {
+			continue
+		}
+
+		if len(s.Composes) != 1 {
+			t.Fatalf("%s: bad compose count in store: %d", c.Path, len(s.Composes))
+		}
+
+		// I have no idea how to get the compose in better way
+		var compose store.Compose
+		for _, c := range s.Composes {
+			compose = c
+			break
+		}
+
+		if diff := cmp.Diff(compose, *c.ExpectedCompose, test.IgnoreDates(), test.IgnoreUuids(), test.Ignore("Targets.Options.Location")); diff != "" {
+			t.Errorf("%s: compose in store isn't the same as expected, diff:\n%s", c.Path, diff)
+		}
+
+	}
+}
+
+func TestComposeStatus(t *testing.T) {
+	var cases = []struct {
+		Fixture        rpmmd_mock.FixtureGenerator
 		Method         string
 		Path           string
 		Body           string
 		ExpectedStatus int
 		ExpectedJSON   string
-		IgnoreFields   []string
 	}{
-		{true, "POST", "/api/v0/compose", `{"blueprint_name": "http-server","compose_type": "tar","branch": "master"}`, http.StatusBadRequest, `{"status":false,"errors":[{"id":"UnknownBlueprint","msg":"Unknown blueprint name: http-server"}]}`, []string{"build_id"}},
-		{false, "POST", "/api/v0/compose", `{"blueprint_name": "test","compose_type": "tar","branch": "master"}`, http.StatusOK, `{"status": true}`, []string{"build_id"}},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/status/30000000-0000-0000-0000-000000000000,30000000-0000-0000-0000-000000000002", ``, http.StatusOK, `{"uuids":[{"id":"30000000-0000-0000-0000-000000000000","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING","job_created":1574857140},{"id":"30000000-0000-0000-0000-000000000002","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FINISHED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140}]}`},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/status/*", ``, http.StatusOK, `{"uuids":[{"id":"30000000-0000-0000-0000-000000000000","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING","job_created":1574857140},{"id":"30000000-0000-0000-0000-000000000001","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"RUNNING","job_created":1574857140,"job_started":1574857140},{"id":"30000000-0000-0000-0000-000000000002","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FINISHED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140},{"id":"30000000-0000-0000-0000-000000000003","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FAILED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140}]}`},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v1/compose/status/30000000-0000-0000-0000-000000000000", ``, http.StatusOK, `{"uuids":[{"id":"30000000-0000-0000-0000-000000000000","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING","job_created":1574857140,"uploads":[{"uuid":"10000000-0000-0000-0000-000000000000","status":"WAITING","provider_name":"aws","image_name":"awsimage","creation_time":1574857140,"settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}]}]}`},
+	}
+
+	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
+		t.Skip("This test is for internal testing only")
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
-		test.SendHTTP(api, c.External, "POST", "/api/v0/blueprints/new", `{"name":"test","description":"Test","packages":[{"name":"httpd","version":"2.4.*"}],"version":"0.0.0"}`)
-		test.TestRoute(t, api, c.External, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, c.IgnoreFields...)
-		test.SendHTTP(api, c.External, "DELETE", "/api/v0/blueprints/delete/test", ``)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
+		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, "id", "job_created", "job_started")
 	}
 }
 
@@ -213,9 +314,55 @@ func TestComposeQueue(t *testing.T) {
 		Body           string
 		ExpectedStatus int
 		ExpectedJSON   string
-		IgnoreFields   []string
 	}{
-		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/queue", ``, http.StatusOK, `{"new":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING"}],"run":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"RUNNING"}]}`, []string{"id", "job_created", "job_started"}},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/queue", ``, http.StatusOK, `{"new":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING"}],"run":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"RUNNING"}]}`},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v1/compose/queue", ``, http.StatusOK, `{"new":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"WAITING","uploads":[{"uuid":"10000000-0000-0000-0000-000000000000","status":"WAITING","provider_name":"aws","image_name":"awsimage","creation_time":1574857140,"settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}]}],"run":[{"blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"RUNNING"}]}`},
+	}
+
+	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
+		t.Skip("This test is for internal testing only")
+	}
+
+	for _, c := range cases {
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
+		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, "id", "job_created", "job_started")
+	}
+}
+
+func TestComposeFinished(t *testing.T) {
+	var cases = []struct {
+		Fixture        rpmmd_mock.FixtureGenerator
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/finished", ``, http.StatusOK, `{"finished":[{"id":"30000000-0000-0000-0000-000000000002","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FINISHED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140}]}`},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v1/compose/finished", ``, http.StatusOK, `{"finished":[{"id":"30000000-0000-0000-0000-000000000002","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FINISHED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140,"uploads":[{"uuid":"10000000-0000-0000-0000-000000000000","status":"WAITING","provider_name":"aws","image_name":"awsimage","creation_time":1574857140,"settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}]}]}`},
+	}
+
+	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
+		t.Skip("This test is for internal testing only")
+	}
+
+	for _, c := range cases {
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
+		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, "id", "job_created", "job_started")
+	}
+}
+
+func TestComposeFailed(t *testing.T) {
+	var cases = []struct {
+		Fixture        rpmmd_mock.FixtureGenerator
+		Method         string
+		Path           string
+		Body           string
+		ExpectedStatus int
+		ExpectedJSON   string
+	}{
+		{rpmmd_mock.BaseFixture, "GET", "/api/v0/compose/failed", ``, http.StatusOK, `{"failed":[{"id":"30000000-0000-0000-0000-000000000003","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FAILED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140}]}`},
+		{rpmmd_mock.BaseFixture, "GET", "/api/v1/compose/failed", ``, http.StatusOK, `{"failed":[{"id":"30000000-0000-0000-0000-000000000003","blueprint":"test","version":"0.0.0","compose_type":"tar","image_size":0,"queue_status":"FAILED","job_created":1574857140,"job_started":1574857140,"job_finished":1574857140,"uploads":[{"uuid":"10000000-0000-0000-0000-000000000000","status":"WAITING","provider_name":"aws","image_name":"awsimage","creation_time":1574857140,"settings":{"region":"frankfurt","accessKeyID":"accesskey","secretAccessKey":"secretkey","bucket":"clay","key":"imagekey"}}]}]}`},
 	}
 
 	if len(os.Getenv("OSBUILD_COMPOSER_TEST_EXTERNAL")) > 0 {
@@ -223,8 +370,8 @@ func TestComposeQueue(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
-		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, c.IgnoreFields...)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
+		test.TestRoute(t, api, false, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON, "id", "job_created", "job_started")
 	}
 }
 
@@ -241,7 +388,7 @@ func TestSourcesNew(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
 		test.SendHTTP(api, true, "DELETE", "/api/v0/projects/source/delete/fish", ``)
 	}
@@ -260,7 +407,7 @@ func TestSourcesDelete(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(rpmmd_mock.BaseFixture)
+		api, _ := createWeldrAPI(rpmmd_mock.BaseFixture)
 		test.SendHTTP(api, true, "POST", "/api/v0/projects/source/new", `{"name": "fish","url": "https://download.opensuse.org/repositories/shells:/fish:/release:/3/Fedora_29/","type": "yum-baseurl","check_ssl": false,"check_gpg": false}`)
 		test.TestRoute(t, api, true, c.Method, c.Path, c.Body, c.ExpectedStatus, c.ExpectedJSON)
 		test.SendHTTP(api, true, "DELETE", "/api/v0/projects/source/delete/fish", ``)
@@ -280,7 +427,7 @@ func TestProjectsDepsolve(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -301,7 +448,7 @@ func TestProjectsInfo(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -323,7 +470,7 @@ func TestModulesInfo(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -342,7 +489,7 @@ func TestProjectsList(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
@@ -364,7 +511,7 @@ func TestModulesList(t *testing.T) {
 	}
 
 	for _, c := range cases {
-		api := createWeldrAPI(c.Fixture)
+		api, _ := createWeldrAPI(c.Fixture)
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
diff --git a/test/cases/ami_empty_blueprint.json b/test/cases/ami_empty_blueprint.json
index ef0991638..105a717f6 100644
--- a/test/cases/ami_empty_blueprint.json
+++ b/test/cases/ami_empty_blueprint.json
@@ -1,7 +1,7 @@
 {
   "compose": {
     "distro": "fedora-30",
-    "filename": "image.ami",
+    "filename": "image.raw.xz",
     "output-format": "ami",
     "blueprint": {}
   },
@@ -118,8 +118,8 @@
     "assembler": {
       "name": "org.osbuild.qemu",
       "options": {
-        "format": "raw",
-        "filename": "image.ami",
+        "format": "raw.xz",
+        "filename": "image.raw.xz",
         "ptuuid": "0x14fc63d2",
         "root_fs_uuid": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
         "size": 3222274048
diff --git a/test/cases/ami_local_boot.json b/test/cases/ami_local_boot.json
index 9df869b7d..37d95e5f2 100644
--- a/test/cases/ami_local_boot.json
+++ b/test/cases/ami_local_boot.json
@@ -1,7 +1,7 @@
 {
   "boot-test": {
     "output-format": "ami",
-    "filename": "image.ami",
+    "filename": "image.raw.xz",
     "boot-type": "qemu",
     "blueprint": {
       "name": "ami-boot-test",
@@ -160,8 +160,8 @@
     "assembler": {
       "name": "org.osbuild.qemu",
       "options": {
-        "format": "raw",
-        "filename": "image.ami",
+        "format": "raw.xz",
+        "filename": "image.raw.xz",
         "ptuuid": "0x14fc63d2",
         "root_fs_uuid": "76a22bf4-f153-4541-b6c7-0332c0dfaeac",
         "size": 3222274048
diff --git a/test/run b/test/run
index c3f777da8..aea1c971d 100755
--- a/test/run
+++ b/test/run
@@ -126,6 +126,11 @@ def run_test(case, store):
         else:
             filename, _ = urllib.request.urlretrieve(case["url"])
 
+        fn, ex = os.path.splitext(filename)
+        if ex == ".xz":
+            with open(fn, "w") as f:
+                subprocess.run(["xz", "--decompress", "--stdout", filename], stdout=f)
+            filename = fn
         info = json.loads(subprocess.check_output(["tools/image-info", filename]))
         if info != case["expected"]:
             with temporary_json_file(case["expected"]) as a, temporary_json_file(info) as b: