Repository: osbuild/osbuild-composer
Pull request #2526 (2022-04-07 04:29:58)

build(deps): bump github.com/google/go-cmp from 0.5.6 to 0.5.7
Bumps [github.com/google/go-cmp](https://github.com/google/go-cmp) from 0.5.6 to 0.5.7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/google/go-cmp/releases">github.com/google/go-cmp's releases</a>.</em></p>
<blockquote>
<h2>v0.5.7</h2>
<p>Reporter changes:</p>
<ul>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/266">#266</a>) Fix textual printing of byte slices</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/275">#275</a>) Reduce minimum length for specialize string diffing</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/276">#276</a>) Use any alias instead of interface{}</li>
</ul>
<p>Code cleanup changes:</p>
<ul>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/281">#281</a>) Update minimum supported version to go1.11</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/282">#282</a>) Drop hacks to work around Go reflection bugs in Go1.9</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/285">#285</a>) Add //go:build lines</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/262">#262</a>) Fix staticcheck findings</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/263">#263</a>) Avoid shadowing variable</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/268">#268</a>) Use sha256 in test</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/271">#271</a>) Fix spelling mistakes</li>
<li>(<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/269">#269</a>) Change build status badge</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/google/go-cmp/commit/039e37cba1f3e52c48404633d6960421b369a19a"><code>039e37c</code></a> Add //go:build lines (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/285">#285</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/3242228551a7556abc624443ef03a6dbfeeacd22"><code>3242228</code></a> Drop hacks to work around Go reflection bugs in Go1.9 (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/282">#282</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/f59cd61478ea347f01124caa5fe7cafe512a4c8f"><code>f59cd61</code></a> Update minimum supported version to go1.11 (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/281">#281</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/6faefd0594fae82639a62c23f0aed1451509dcc0"><code>6faefd0</code></a> Reduce minimum length for specialize string diffing (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/275">#275</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/f1773ad7bdb1ddccb1801c5b8ca4311cf0d301e4"><code>f1773ad</code></a> Use any alias instead of interface{} (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/276">#276</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/9094ef9afacbd93e9f232d0ccadaf8530f353441"><code>9094ef9</code></a> Change build status badge (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/269">#269</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/3ee52c8a5607a16505c9c7141153ff82d360817a"><code>3ee52c8</code></a> Fix spelling mistakes (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/271">#271</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/395a0ac549058d781199661ca2e37cc636db43e4"><code>395a0ac</code></a> Use sha256 in test (<a href="https://github-redirect.dependabot.com/google/go-cmp/issues/268">#268</a>)</li>
<li><a href="https://github.com/google/go-cmp/commit/402949e8139bb890c71a707b6faf6dd05c92f4e5"><code>402949e</code></a> Merge pull request <a href="https://github-redirect.dependabot.com/google/go-cmp/issues/266">#266</a> from dsnet/fix-format</li>
<li><a href="https://github.com/google/go-cmp/commit/d5fcb386864232c4b315ea7f9838e455a2ce3cda"><code>d5fcb38</code></a> Fix textual printing of byte slices</li>
<li>Additional commits viewable in <a href="https://github.com/google/go-cmp/compare/v0.5.6...v0.5.7">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/google/go-cmp&package-manager=go_modules&previous-version=0.5.6&new-version=0.5.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index b63891e1c..234ee49d2 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -442,7 +442,7 @@ API:
         RUNNER:
           - aws/fedora-35-x86_64
 
-libvirt:
+.libvirt_integration:
   stage: test
   extends: .terraform/openstack
   rules:
@@ -450,7 +450,7 @@ libvirt:
     - !reference [.nightly_rules, rules]
   script:
     - schutzbot/deploy.sh
-    - /usr/libexec/tests/osbuild-composer/libvirt.sh
+    - /usr/libexec/tests/osbuild-composer/${SCRIPT}
   parallel:
     matrix:
       - RUNNER:
@@ -460,6 +460,21 @@ libvirt:
           - rhos-01/rhel-9.0-nightly-x86_64
           - rhos-01/centos-stream-9-x86_64
 
+libvirt.sh:
+  extends: .libvirt_integration
+  variables:
+    SCRIPT: libvirt.sh
+
+generic_s3.sh:
+  extends: .libvirt_integration
+  variables:
+    SCRIPT: generic_s3.sh
+
+aws_s3.sh:
+  extends: .libvirt_integration
+  variables:
+    SCRIPT: aws_s3.sh
+
 RHEL 9 on 8:
   stage: test
   extends: .terraform
diff --git a/Makefile b/Makefile
index 3df7af7c6..b554c32d5 100644
--- a/Makefile
+++ b/Makefile
@@ -108,7 +108,7 @@ man: $(MANPAGES_TROFF)
 
 .PHONY: build
 build:
-	- mkdir bin
+	- mkdir -p bin
 	go build -o bin/osbuild-composer ./cmd/osbuild-composer/
 	go build -o bin/osbuild-worker ./cmd/osbuild-worker/
 	go build -o bin/osbuild-pipeline ./cmd/osbuild-pipeline/
@@ -116,6 +116,7 @@ build:
 	go build -o bin/osbuild-upload-aws ./cmd/osbuild-upload-aws/
 	go build -o bin/osbuild-upload-gcp ./cmd/osbuild-upload-gcp/
 	go build -o bin/osbuild-upload-oci ./cmd/osbuild-upload-oci/
+	go build -o bin/osbuild-upload-generic-s3 ./cmd/osbuild-upload-generic-s3/
 	go build -o bin/osbuild-mock-openid-provider ./cmd/osbuild-mock-openid-provider
 	go build -o bin/osbuild-service-maintenance ./cmd/osbuild-service-maintenance
 	go test -c -tags=integration -o bin/osbuild-composer-cli-tests ./cmd/osbuild-composer-cli-tests/main_test.go
diff --git a/cmd/osbuild-upload-generic-s3/main.go b/cmd/osbuild-upload-generic-s3/main.go
new file mode 100644
index 000000000..956daebc1
--- /dev/null
+++ b/cmd/osbuild-upload-generic-s3/main.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+	"flag"
+	"fmt"
+
+	"github.com/aws/aws-sdk-go/aws"
+
+	"github.com/osbuild/osbuild-composer/internal/cloud/awscloud"
+)
+
+func main() {
+	var accessKeyID string
+	var secretAccessKey string
+	var sessionToken string
+	var region string
+	var endpoint string
+	var bucketName string
+	var keyName string
+	var filename string
+	flag.StringVar(&accessKeyID, "access-key-id", "", "access key ID")
+	flag.StringVar(&secretAccessKey, "secret-access-key", "", "secret access key")
+	flag.StringVar(&sessionToken, "session-token", "", "session token")
+	flag.StringVar(&region, "region", "", "target region")
+	flag.StringVar(&endpoint, "endpoint", "", "target endpoint")
+	flag.StringVar(&bucketName, "bucket", "", "target S3 bucket name")
+	flag.StringVar(&keyName, "key", "", "target S3 key name")
+	flag.StringVar(&filename, "image", "", "image file to upload")
+	flag.Parse()
+
+	a, err := awscloud.NewForEndpoint(endpoint, region, accessKeyID, secretAccessKey, sessionToken)
+	if err != nil {
+		println(err.Error())
+		return
+	}
+
+	uploadOutput, err := a.Upload(filename, bucketName, keyName)
+	if err != nil {
+		println(err.Error())
+		return
+	}
+
+	fmt.Printf("file uploaded to %s\n", aws.StringValue(&uploadOutput.Location))
+}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 18bc0fff5..afab89163 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -30,13 +30,14 @@ import (
 )
 
 type OSBuildJobImpl struct {
-	Store       string
-	Output      string
-	KojiServers map[string]koji.GSSAPICredentials
-	GCPCreds    []byte
-	AzureCreds  *azure.Credentials
-	AWSCreds    string
-	AWSBucket   string
+	Store          string
+	Output         string
+	KojiServers    map[string]koji.GSSAPICredentials
+	GCPCreds       []byte
+	AzureCreds     *azure.Credentials
+	AWSCreds       string
+	AWSBucket      string
+	GenericS3Creds string
 }
 
 // Returns an *awscloud.AWS object with the credentials of the request. If they
@@ -52,6 +53,16 @@ func (impl *OSBuildJobImpl) getAWS(region string, accessId string, secret string
 	}
 }
 
+func (impl *OSBuildJobImpl) getAWSForEndpoint(endpoint, region, accessId, secret, token string) (*awscloud.AWS, error) {
+	if accessId != "" && secret != "" {
+		return awscloud.NewForEndpoint(endpoint, region, accessId, secret, token)
+	}
+	if impl.GenericS3Creds != "" {
+		return awscloud.NewForEndpointFromFile(impl.GenericS3Creds, endpoint, region)
+	}
+	return nil, fmt.Errorf("no credentials found")
+}
+
 func validateResult(result *worker.OSBuildJobResult, jobID string) {
 	logWithId := logrus.WithField("jobId", jobID)
 	if result.JobError != nil {
@@ -71,6 +82,65 @@ func validateResult(result *worker.OSBuildJobResult, jobID string) {
 	result.Success = true
 }
 
+func uploadToS3(a *awscloud.AWS, outputDirectory, exportPath, bucket, key, filename string, osbuildJobResult *worker.OSBuildJobResult, genericS3 bool, streamOptimized bool, streamOptimizedPath string) (err error) {
+	imagePath := path.Join(outputDirectory, exportPath, filename)
+
+	// *** 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)
+	if err != nil {
+		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+		return
+	}
+
+	url, err := a.S3ObjectPresignedURL(bucket, key)
+	if err != nil {
+		osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+		return
+	}
+
+	var targetResult *target.TargetResult
+	if genericS3 {
+		targetResult = target.NewGenericS3TargetResult(&target.GenericS3TargetResultOptions{URL: url})
+	} else {
+		targetResult = target.NewAWSS3TargetResult(&target.AWSS3TargetResultOptions{URL: url})
+	}
+	osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, targetResult)
+
+	osbuildJobResult.Success = true
+	osbuildJobResult.UploadStatus = "success"
+
+	return
+}
+
 func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 	logWithId := logrus.WithField("jobId", job.Id().String())
 	// Initialize variable needed for reporting back to osbuild-composer.
@@ -314,59 +384,26 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				return nil
 			}
 
-			key := options.Key
-			if key == "" {
-				key = uuid.New().String()
-			}
-			key += "-" + options.Filename
-
 			bucket := options.Bucket
 			if impl.AWSBucket != "" {
 				bucket = impl.AWSBucket
 			}
 
-			imagePath := path.Join(outputDirectory, exportPath, options.Filename)
-
-			// *** 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 args.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 ***
-
-			_, err = a.Upload(imagePath, bucket, key)
+			err = uploadToS3(a, outputDirectory, exportPath, bucket, options.Key, options.Filename, osbuildJobResult, false, args.StreamOptimized, streamOptimizedPath)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
 				return nil
 			}
-			url, err := a.S3ObjectPresignedURL(bucket, key)
+		case *target.GenericS3TargetOptions:
+			a, err := impl.getAWSForEndpoint(options.Endpoint, options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
 			if err != nil {
-				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorUploadingImage, err.Error())
+				osbuildJobResult.JobError = clienterrors.WorkerClientError(clienterrors.ErrorInvalidConfig, err.Error())
 				return nil
 			}
 
-			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAWSS3TargetResult(&target.AWSS3TargetResultOptions{URL: url}))
-
-			osbuildJobResult.Success = true
-			osbuildJobResult.UploadStatus = "success"
+			err = uploadToS3(a, outputDirectory, exportPath, options.Bucket, options.Key, options.Filename, osbuildJobResult, true, args.StreamOptimized, streamOptimizedPath)
+			if err != nil {
+				return nil
+			}
 		case *target.AzureTargetOptions:
 			azureStorageClient, err := azure.NewStorageClient(options.StorageAccount, options.StorageAccessKey)
 			if err != nil {
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index e6cacb7d6..96b5fae60 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -139,6 +139,9 @@ func main() {
 			Credentials string `toml:"credentials"`
 			Bucket      string `toml:"bucket"`
 		} `toml:"aws"`
+		GenericS3 *struct {
+			Credentials string `toml:"credentials"`
+		} `toml:"generic_s3"`
 		Authentication *struct {
 			OAuthURL         string `toml:"oauth_url"`
 			OfflineTokenPath string `toml:"offline_token"`
@@ -306,6 +309,11 @@ func main() {
 		awsBucket = config.AWS.Bucket
 	}
 
+	var genericS3Credentials = ""
+	if config.GenericS3 != nil {
+		genericS3Credentials = config.GenericS3.Credentials
+	}
+
 	// depsolve jobs can be done during other jobs
 	depsolveCtx, depsolveCtxCancel := context.WithCancel(context.Background())
 	defer depsolveCtxCancel()
@@ -340,13 +348,14 @@ func main() {
 	// non-depsolve job
 	jobImpls := map[string]JobImplementation{
 		"osbuild": &OSBuildJobImpl{
-			Store:       store,
-			Output:      output,
-			KojiServers: kojiServers,
-			GCPCreds:    gcpCredentials,
-			AzureCreds:  azureCredentials,
-			AWSCreds:    awsCredentials,
-			AWSBucket:   awsBucket,
+			Store:          store,
+			Output:         output,
+			KojiServers:    kojiServers,
+			GCPCreds:       gcpCredentials,
+			AzureCreds:     azureCredentials,
+			AWSCreds:       awsCredentials,
+			AWSBucket:      awsBucket,
+			GenericS3Creds: genericS3Credentials,
 		},
 		"osbuild-koji": &OSBuildKojiJobImpl{
 			Store:              store,
diff --git a/go.mod b/go.mod
index fab48e913..9d18c6d23 100644
--- a/go.mod
+++ b/go.mod
@@ -26,7 +26,7 @@ require (
 	github.com/golang-jwt/jwt v3.2.2+incompatible
 	github.com/golang/glog v1.0.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/google/go-cmp v0.5.6
+	github.com/google/go-cmp v0.5.7
 	github.com/google/uuid v1.3.0
 	github.com/gophercloud/gophercloud v0.22.0
 	github.com/hashicorp/go-retryablehttp v0.7.0
diff --git a/go.sum b/go.sum
index 2f6ae9fec..f642db388 100644
--- a/go.sum
+++ b/go.sum
@@ -285,8 +285,9 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
+github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
diff --git a/internal/cloud/awscloud/awscloud.go b/internal/cloud/awscloud/awscloud.go
index e2282ef08..11c5017ab 100644
--- a/internal/cloud/awscloud/awscloud.go
+++ b/internal/cloud/awscloud/awscloud.go
@@ -22,11 +22,14 @@ type AWS struct {
 }
 
 // Create a new session from the credentials and the region and returns an *AWS object initialized with it.
-func newAwsFromCreds(creds *credentials.Credentials, region string) (*AWS, error) {
+func newAwsFromCreds(creds *credentials.Credentials, region string, endpoint *string) (*AWS, error) {
 	// Create a Session with a custom region
+	s3ForcePathStyle := endpoint != nil
 	sess, err := session.NewSession(&aws.Config{
-		Credentials: creds,
-		Region:      aws.String(region),
+		Credentials:      creds,
+		Region:           aws.String(region),
+		Endpoint:         endpoint,
+		S3ForcePathStyle: &s3ForcePathStyle,
 	})
 	if err != nil {
 		return nil, err
@@ -41,7 +44,7 @@ func newAwsFromCreds(creds *credentials.Credentials, region string) (*AWS, error
 
 // Initialize a new AWS object from individual bits. SessionToken is optional
 func New(region string, accessKeyID string, accessKey string, sessionToken string) (*AWS, error) {
-	return newAwsFromCreds(credentials.NewStaticCredentials(accessKeyID, accessKey, sessionToken), region)
+	return newAwsFromCreds(credentials.NewStaticCredentials(accessKeyID, accessKey, sessionToken), region, nil)
 }
 
 // Initializes a new AWS object with the credentials info found at filename's location.
@@ -54,13 +57,31 @@ func New(region string, accessKeyID string, accessKey string, sessionToken strin
 // "AWS_SHARED_CREDENTIALS_FILE" env variable or will default to
 // $HOME/.aws/credentials.
 func NewFromFile(filename string, region string) (*AWS, error) {
-	return newAwsFromCreds(credentials.NewSharedCredentials(filename, "default"), region)
+	return newAwsFromCreds(credentials.NewSharedCredentials(filename, "default"), region, nil)
 }
 
 // Initialize a new AWS object from defaults.
 // Looks for env variables, shared credential file, and EC2 Instance Roles.
 func NewDefault(region string) (*AWS, error) {
-	return newAwsFromCreds(nil, region)
+	return newAwsFromCreds(nil, region, nil)
+}
+
+// Initialize a new AWS object targeting a specific endpoint from individual bits. SessionToken is optional
+func NewForEndpoint(endpoint, region string, accessKeyID string, accessKey string, sessionToken string) (*AWS, error) {
+	return newAwsFromCreds(credentials.NewStaticCredentials(accessKeyID, accessKey, sessionToken), region, &endpoint)
+}
+
+// Initializes a new AWS object targeting a specific endpoint with the credentials info found at filename's location.
+// The credential files should match the AWS format, such as:
+// [default]
+// aws_access_key_id = secretString1
+// aws_secret_access_key = secretString2
+//
+// If filename is empty the underlying function will look for the
+// "AWS_SHARED_CREDENTIALS_FILE" env variable or will default to
+// $HOME/.aws/credentials.
+func NewForEndpointFromFile(filename string, endpoint, region string) (*AWS, error) {
+	return newAwsFromCreds(credentials.NewSharedCredentials(filename, "default"), region, &endpoint)
 }
 
 func (a *AWS) Upload(filename, bucket, key string) (*s3manager.UploadOutput, error) {
diff --git a/internal/distro/rhel85/pipelines.go b/internal/distro/rhel85/pipelines.go
index 1d4e7da25..abce49732 100644
--- a/internal/distro/rhel85/pipelines.go
+++ b/internal/distro/rhel85/pipelines.go
@@ -55,7 +55,7 @@ func qcow2Pipelines(t *imageType, customizations *blueprint.Customizations, opti
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "0.10")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, osbuild.QCOW2Options{Compat: "0.10"})
 	pipelines = append(pipelines, *qemuPipeline)
 
 	return pipelines, nil
@@ -99,7 +99,7 @@ func vhdPipelines(t *imageType, customizations *blueprint.Customizations, option
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vpc", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVPC, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -130,7 +130,7 @@ func vmdkPipelines(t *imageType, customizations *blueprint.Customizations, optio
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vmdk", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVMDK, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -161,7 +161,7 @@ func openstackPipelines(t *imageType, customizations *blueprint.Customizations,
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -1271,12 +1271,15 @@ func xzArchivePipeline(inputPipelineName, inputFilename, outputFilename string)
 	return p
 }
 
-func qemuPipeline(inputPipelineName, inputFilename, outputFilename, format, qcow2Compat string) *osbuild.Pipeline {
+func qemuPipeline(inputPipelineName, inputFilename, outputFilename string, format osbuild.QEMUFormat, formatOptions osbuild.QEMUFormatOptions) *osbuild.Pipeline {
 	p := new(osbuild.Pipeline)
-	p.Name = format
+	p.Name = string(format)
 	p.Build = "name:build"
 
-	qemuStage := osbuild.NewQEMUStage(qemuStageOptions(outputFilename, format, qcow2Compat), osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename))
+	qemuStage := osbuild.NewQEMUStage(
+		osbuild.NewQEMUStageOptions(outputFilename, format, formatOptions),
+		osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename),
+	)
 	p.AddStage(qemuStage)
 	return p
 }
diff --git a/internal/distro/rhel85/stage_options.go b/internal/distro/rhel85/stage_options.go
index 8a912c800..fceffaa00 100644
--- a/internal/distro/rhel85/stage_options.go
+++ b/internal/distro/rhel85/stage_options.go
@@ -229,32 +229,6 @@ func xorrisofsStageOptions(filename string, arch string, isolinux bool) *osbuild
 	return options
 }
 
-func qemuStageOptions(filename, format, compat string) *osbuild.QEMUStageOptions {
-	var options osbuild.QEMUFormatOptions
-	switch format {
-	case "qcow2":
-		options = osbuild.Qcow2Options{
-			Type:   "qcow2",
-			Compat: compat,
-		}
-	case "vpc":
-		options = osbuild.VPCOptions{
-			Type: "vpc",
-		}
-	case "vmdk":
-		options = osbuild.VMDKOptions{
-			Type: "vmdk",
-		}
-	default:
-		panic("unknown format in qemu stage: " + format)
-	}
-
-	return &osbuild.QEMUStageOptions{
-		Filename: filename,
-		Format:   options,
-	}
-}
-
 func nginxConfigStageOptions(path, htmlRoot, listen string) *osbuild.NginxConfigStageOptions {
 	// configure nginx to work in an unprivileged container
 	cfg := &osbuild.NginxConfig{
diff --git a/internal/distro/rhel86/pipelines.go b/internal/distro/rhel86/pipelines.go
index f7e73c012..258381dfb 100644
--- a/internal/distro/rhel86/pipelines.go
+++ b/internal/distro/rhel86/pipelines.go
@@ -34,7 +34,7 @@ func qcow2Pipelines(t *imageType, customizations *blueprint.Customizations, opti
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "0.10")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, osbuild.QCOW2Options{Compat: "0.10"})
 	pipelines = append(pipelines, *qemuPipeline)
 
 	return pipelines, nil
@@ -75,7 +75,7 @@ func vhdPipelines(t *imageType, customizations *blueprint.Customizations, option
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vpc", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVPC, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -103,7 +103,7 @@ func vmdkPipelines(t *imageType, customizations *blueprint.Customizations, optio
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vmdk", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVMDK, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -131,7 +131,7 @@ func openstackPipelines(t *imageType, customizations *blueprint.Customizations,
 		return nil, err
 	}
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -1033,12 +1033,15 @@ func xzArchivePipeline(inputPipelineName, inputFilename, outputFilename string)
 	return p
 }
 
-func qemuPipeline(inputPipelineName, inputFilename, outputFilename, format, qcow2Compat string) *osbuild.Pipeline {
+func qemuPipeline(inputPipelineName, inputFilename, outputFilename string, format osbuild.QEMUFormat, formatOptions osbuild.QEMUFormatOptions) *osbuild.Pipeline {
 	p := new(osbuild.Pipeline)
-	p.Name = format
+	p.Name = string(format)
 	p.Build = "name:build"
 
-	qemuStage := osbuild.NewQEMUStage(qemuStageOptions(outputFilename, format, qcow2Compat), osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename))
+	qemuStage := osbuild.NewQEMUStage(
+		osbuild.NewQEMUStageOptions(outputFilename, format, formatOptions),
+		osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename),
+	)
 	p.AddStage(qemuStage)
 	return p
 }
diff --git a/internal/distro/rhel86/stage_options.go b/internal/distro/rhel86/stage_options.go
index 9ddc70e18..911dbcab7 100644
--- a/internal/distro/rhel86/stage_options.go
+++ b/internal/distro/rhel86/stage_options.go
@@ -278,32 +278,6 @@ func xorrisofsStageOptions(filename, isolabel, arch string, isolinux bool) *osbu
 	return options
 }
 
-func qemuStageOptions(filename, format, compat string) *osbuild.QEMUStageOptions {
-	var options osbuild.QEMUFormatOptions
-	switch format {
-	case "qcow2":
-		options = osbuild.Qcow2Options{
-			Type:   "qcow2",
-			Compat: compat,
-		}
-	case "vpc":
-		options = osbuild.VPCOptions{
-			Type: "vpc",
-		}
-	case "vmdk":
-		options = osbuild.VMDKOptions{
-			Type: "vmdk",
-		}
-	default:
-		panic("unknown format in qemu stage: " + format)
-	}
-
-	return &osbuild.QEMUStageOptions{
-		Filename: filename,
-		Format:   options,
-	}
-}
-
 func nginxConfigStageOptions(path, htmlRoot, listen string) *osbuild.NginxConfigStageOptions {
 	// configure nginx to work in an unprivileged container
 	cfg := &osbuild.NginxConfig{
diff --git a/internal/distro/rhel90/pipelines.go b/internal/distro/rhel90/pipelines.go
index 48c15a66e..b0aab697e 100644
--- a/internal/distro/rhel90/pipelines.go
+++ b/internal/distro/rhel90/pipelines.go
@@ -35,7 +35,7 @@ func qcow2Pipelines(t *imageType, customizations *blueprint.Customizations, opti
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "1.1")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, osbuild.QCOW2Options{Compat: "1.1"})
 	pipelines = append(pipelines, *qemuPipeline)
 
 	return pipelines, nil
@@ -72,7 +72,7 @@ func vhdPipelines(t *imageType, customizations *blueprint.Customizations, option
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vpc", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVPC, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -97,7 +97,7 @@ func vmdkPipelines(t *imageType, customizations *blueprint.Customizations, optio
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vmdk", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVMDK, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -122,7 +122,7 @@ func openstackPipelines(t *imageType, customizations *blueprint.Customizations,
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -1025,12 +1025,15 @@ func xzArchivePipeline(inputPipelineName, inputFilename, outputFilename string)
 	return p
 }
 
-func qemuPipeline(inputPipelineName, inputFilename, outputFilename, format, qcow2Compat string) *osbuild.Pipeline {
+func qemuPipeline(inputPipelineName, inputFilename, outputFilename string, format osbuild.QEMUFormat, formatOptions osbuild.QEMUFormatOptions) *osbuild.Pipeline {
 	p := new(osbuild.Pipeline)
-	p.Name = format
+	p.Name = string(format)
 	p.Build = "name:build"
 
-	qemuStage := osbuild.NewQEMUStage(qemuStageOptions(outputFilename, format, qcow2Compat), osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename))
+	qemuStage := osbuild.NewQEMUStage(
+		osbuild.NewQEMUStageOptions(outputFilename, format, formatOptions),
+		osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename),
+	)
 	p.AddStage(qemuStage)
 	return p
 }
diff --git a/internal/distro/rhel90/stage_options.go b/internal/distro/rhel90/stage_options.go
index 949d891bc..cb33cc793 100644
--- a/internal/distro/rhel90/stage_options.go
+++ b/internal/distro/rhel90/stage_options.go
@@ -278,32 +278,6 @@ func xorrisofsStageOptions(filename, isolabel, arch string, isolinux bool) *osbu
 	return options
 }
 
-func qemuStageOptions(filename, format, compat string) *osbuild.QEMUStageOptions {
-	var options osbuild.QEMUFormatOptions
-	switch format {
-	case "qcow2":
-		options = osbuild.Qcow2Options{
-			Type:   "qcow2",
-			Compat: compat,
-		}
-	case "vpc":
-		options = osbuild.VPCOptions{
-			Type: "vpc",
-		}
-	case "vmdk":
-		options = osbuild.VMDKOptions{
-			Type: "vmdk",
-		}
-	default:
-		panic("unknown format in qemu stage: " + format)
-	}
-
-	return &osbuild.QEMUStageOptions{
-		Filename: filename,
-		Format:   options,
-	}
-}
-
 func nginxConfigStageOptions(path, htmlRoot, listen string) *osbuild.NginxConfigStageOptions {
 	// configure nginx to work in an unprivileged container
 	cfg := &osbuild.NginxConfig{
diff --git a/internal/distro/rhel90beta/pipelines.go b/internal/distro/rhel90beta/pipelines.go
index 2ef052060..e8b989f04 100644
--- a/internal/distro/rhel90beta/pipelines.go
+++ b/internal/distro/rhel90beta/pipelines.go
@@ -51,7 +51,7 @@ func qcow2Pipelines(t *imageType, customizations *blueprint.Customizations, opti
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "1.1")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, osbuild.QCOW2Options{Compat: "1.1"})
 	pipelines = append(pipelines, *qemuPipeline)
 
 	return pipelines, nil
@@ -92,7 +92,7 @@ func vhdPipelines(t *imageType, customizations *blueprint.Customizations, option
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vpc", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVPC, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -121,7 +121,7 @@ func vmdkPipelines(t *imageType, customizations *blueprint.Customizations, optio
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "vmdk", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatVMDK, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -149,7 +149,7 @@ func openstackPipelines(t *imageType, customizations *blueprint.Customizations,
 	imagePipeline := liveImagePipeline(treePipeline.Name, diskfile, partitionTable, t.arch, kernelVer)
 	pipelines = append(pipelines, *imagePipeline)
 
-	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, "qcow2", "")
+	qemuPipeline := qemuPipeline(imagePipeline.Name, diskfile, t.filename, osbuild.QEMUFormatQCOW2, nil)
 	pipelines = append(pipelines, *qemuPipeline)
 	return pipelines, nil
 }
@@ -1087,12 +1087,15 @@ func xzArchivePipeline(inputPipelineName, inputFilename, outputFilename string)
 	return p
 }
 
-func qemuPipeline(inputPipelineName, inputFilename, outputFilename, format, qcow2Compat string) *osbuild.Pipeline {
+func qemuPipeline(inputPipelineName, inputFilename, outputFilename string, format osbuild.QEMUFormat, formatOptions osbuild.QEMUFormatOptions) *osbuild.Pipeline {
 	p := new(osbuild.Pipeline)
-	p.Name = format
+	p.Name = string(format)
 	p.Build = "name:build"
 
-	qemuStage := osbuild.NewQEMUStage(qemuStageOptions(outputFilename, format, qcow2Compat), osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename))
+	qemuStage := osbuild.NewQEMUStage(
+		osbuild.NewQEMUStageOptions(outputFilename, format, formatOptions),
+		osbuild.NewQemuStagePipelineFilesInputs(inputPipelineName, inputFilename),
+	)
 	p.AddStage(qemuStage)
 	return p
 }
diff --git a/internal/distro/rhel90beta/stage_options.go b/internal/distro/rhel90beta/stage_options.go
index 2eaa4409d..25660f0fd 100644
--- a/internal/distro/rhel90beta/stage_options.go
+++ b/internal/distro/rhel90beta/stage_options.go
@@ -207,29 +207,3 @@ func xorrisofsStageOptions(filename string, arch string) *osbuild.XorrisofsStage
 		IsohybridMBR: "/usr/share/syslinux/isohdpfx.bin",
 	}
 }
-
-func qemuStageOptions(filename, format, compat string) *osbuild.QEMUStageOptions {
-	var options osbuild.QEMUFormatOptions
-	switch format {
-	case "qcow2":
-		options = osbuild.Qcow2Options{
-			Type:   "qcow2",
-			Compat: compat,
-		}
-	case "vpc":
-		options = osbuild.VPCOptions{
-			Type: "vpc",
-		}
-	case "vmdk":
-		options = osbuild.VMDKOptions{
-			Type: "vmdk",
-		}
-	default:
-		panic("unknown format in qemu stage: " + format)
-	}
-
-	return &osbuild.QEMUStageOptions{
-		Filename: filename,
-		Format:   options,
-	}
-}
diff --git a/internal/osbuild2/qemu_stage.go b/internal/osbuild2/qemu_stage.go
index 8093babe5..bd7e91dc9 100644
--- a/internal/osbuild2/qemu_stage.go
+++ b/internal/osbuild2/qemu_stage.go
@@ -20,34 +20,145 @@ type QEMUStageOptions struct {
 
 func (QEMUStageOptions) isStageOptions() {}
 
+type QEMUFormat string
+type VMDKSubformat string
+
+const (
+	QEMUFormatQCOW2 QEMUFormat = "qcow2"
+	QEMUFormatVDI   QEMUFormat = "vdi"
+	QEMUFormatVMDK  QEMUFormat = "vmdk"
+	QEMUFormatVPC   QEMUFormat = "vpc"
+	QEMUFormatVHDX  QEMUFormat = "vhdx"
+
+	VMDKSubformatMonolithicSparse     VMDKSubformat = "monolithicSparse"
+	VMDKSubformatMonolithicFlat       VMDKSubformat = "monolithicFlat"
+	VMDKSubformatTwoGbMaxExtentSparse VMDKSubformat = "twoGbMaxExtentSparse"
+	VMDKSubformatTwoGbMaxExtentFlat   VMDKSubformat = "twoGbMaxExtentFlat"
+	VMDKSubformatStreamOptimized      VMDKSubformat = "streamOptimized"
+)
+
 type QEMUFormatOptions interface {
 	isQEMUFormatOptions()
+	validate() error
+	formatType() QEMUFormat
 }
 
-type Qcow2Options struct {
+type QCOW2Options struct {
 	// The type of the format must be 'qcow2'
-	Type string `json:"type"`
+	Type QEMUFormat `json:"type"`
 
 	// The qcow2-compatibility-version to use
 	Compat string `json:"compat"`
 }
 
-func (Qcow2Options) isQEMUFormatOptions() {}
+func (QCOW2Options) isQEMUFormatOptions() {}
+
+func (o QCOW2Options) validate() error {
+	if o.Type != QEMUFormatQCOW2 {
+		return fmt.Errorf("invalid format type %q for %q options", o.Type, QEMUFormatQCOW2)
+	}
+	return nil
+}
+
+func (o QCOW2Options) formatType() QEMUFormat {
+	return o.Type
+}
+
+type VDIOptions struct {
+	// The type of the format must be 'vdi'
+	Type QEMUFormat `json:"type"`
+}
+
+func (VDIOptions) isQEMUFormatOptions() {}
+
+func (o VDIOptions) validate() error {
+	if o.Type != QEMUFormatVDI {
+		return fmt.Errorf("invalid format type %q for %q options", o.Type, QEMUFormatVDI)
+	}
+	return nil
+}
+
+func (o VDIOptions) formatType() QEMUFormat {
+	return o.Type
+}
 
 type VPCOptions struct {
 	// The type of the format must be 'vpc'
-	Type string `json:"type"`
+	Type QEMUFormat `json:"type"`
 }
 
 func (VPCOptions) isQEMUFormatOptions() {}
 
+func (o VPCOptions) validate() error {
+	if o.Type != QEMUFormatVPC {
+		return fmt.Errorf("invalid format type %q for %q options", o.Type, QEMUFormatVPC)
+	}
+	return nil
+}
+
+func (o VPCOptions) formatType() QEMUFormat {
+	return o.Type
+}
+
 type VMDKOptions struct {
-	// The type of the format must be 'vpc'
-	Type string `json:"type"`
+	// The type of the format must be 'vmdk'
+	Type QEMUFormat `json:"type"`
+
+	Subformat VMDKSubformat `json:"subformat,omitempty"`
 }
 
 func (VMDKOptions) isQEMUFormatOptions() {}
 
+func (o VMDKOptions) validate() error {
+	if o.Type != QEMUFormatVMDK {
+		return fmt.Errorf("invalid format type %q for %q options", o.Type, QEMUFormatVMDK)
+	}
+
+	if o.Subformat != "" {
+		allowedVMDKSubformats := []VMDKSubformat{
+			VMDKSubformatMonolithicFlat,
+			VMDKSubformatMonolithicSparse,
+			VMDKSubformatTwoGbMaxExtentFlat,
+			VMDKSubformatTwoGbMaxExtentSparse,
+			VMDKSubformatStreamOptimized,
+		}
+		valid := false
+		for _, value := range allowedVMDKSubformats {
+			if o.Subformat == value {
+				valid = true
+				break
+			}
+		}
+		if !valid {
+			return fmt.Errorf("'subformat' option does not allow %q as a value", o.Subformat)
+		}
+	}
+
+	return nil
+}
+
+func (o VMDKOptions) formatType() QEMUFormat {
+	return o.Type
+}
+
+type VHDXOptions struct {
+	// The type of the format must be 'vhdx'
+	Type QEMUFormat `json:"type"`
+}
+
+func (VHDXOptions) isQEMUFormatOptions() {}
+
+func (o VHDXOptions) validate() error {
+	if o.Type != QEMUFormatVHDX {
+		return fmt.Errorf("invalid format type %q for %q options", o.Type, QEMUFormatVHDX)
+	}
+	return nil
+}
+
+func (o VHDXOptions) formatType() QEMUFormat {
+	return o.Type
+}
+
 type QEMUStageInputs struct {
 	Image *QEMUStageInput `json:"image"`
 }
@@ -81,26 +192,77 @@ func NewQEMUStage(options *QEMUStageOptions, inputs *QEMUStageInputs) *Stage {
 	}
 }
 
+// NewQEMUStageOptions creates a new QEMU Stage options object.
+//
+// In case the format-specific options are provided, they are used for
+// the Stage options.
+func NewQEMUStageOptions(filename string, format QEMUFormat, formatOptions QEMUFormatOptions) *QEMUStageOptions {
+	if formatOptions != nil {
+		// If the format type is not set explicitly in the provided format
+		// options, set it to the appropriate value based on the format options
+		// object type.
+		if formatOptions.formatType() == "" {
+			switch o := formatOptions.(type) {
+			case QCOW2Options:
+				o.Type = QEMUFormatQCOW2
+				formatOptions = o
+			case VDIOptions:
+				o.Type = QEMUFormatVDI
+				formatOptions = o
+			case VPCOptions:
+				o.Type = QEMUFormatVPC
+				formatOptions = o
+			case VMDKOptions:
+				o.Type = QEMUFormatVMDK
+				formatOptions = o
+			case VHDXOptions:
+				o.Type = QEMUFormatVHDX
+				formatOptions = o
+			default:
+				panic(fmt.Sprintf("unknown format options type in qemu stage: %t", o))
+			}
+		}
+
+		// Ensure that the explicitly provided QEMU format and the format set
+		// in the format options structure (set by user or by this function
+		// above) are matching.
+		if t := formatOptions.formatType(); t != format {
+			panic(fmt.Sprintf("mismatch between passed format type %q and format options type %q", format, t))
+		}
+
+		if err := formatOptions.validate(); err != nil {
+			panic(err)
+		}
+	} else {
+		switch format {
+		case QEMUFormatQCOW2:
+			formatOptions = QCOW2Options{Type: format}
+		case QEMUFormatVDI:
+			formatOptions = VDIOptions{Type: format}
+		case QEMUFormatVPC:
+			formatOptions = VPCOptions{Type: format}
+		case QEMUFormatVMDK:
+			formatOptions = VMDKOptions{Type: format}
+		case QEMUFormatVHDX:
+			formatOptions = VHDXOptions{Type: format}
+		default:
+			panic("unknown format in qemu stage: " + format)
+		}
+	}
+
+	return &QEMUStageOptions{
+		Filename: filename,
+		Format:   formatOptions,
+	}
+}
+
 // alias for custom marshaller
 type qemuStageOptions QEMUStageOptions
 
 // Custom marshaller for validating
 func (options QEMUStageOptions) MarshalJSON() ([]byte, error) {
-	switch o := options.Format.(type) {
-	case Qcow2Options:
-		if o.Type != "qcow2" {
-			return nil, fmt.Errorf("invalid format type %q for qcow2 options", o.Type)
-		}
-	case VPCOptions:
-		if o.Type != "vpc" {
-			return nil, fmt.Errorf("invalid format type %q for vpc options", o.Type)
-		}
-	case VMDKOptions:
-		if o.Type != "vmdk" {
-			return nil, fmt.Errorf("invalid format type %q for vmdk options", o.Type)
-		}
-	default:
-		return nil, fmt.Errorf("unknown format options in QEMU stage: %#v", options.Format)
+	if err := options.Format.validate(); err != nil {
+		return nil, err
 	}
 
 	return json.Marshal(qemuStageOptions(options))
diff --git a/internal/target/generic_s3_target.go b/internal/target/generic_s3_target.go
new file mode 100644
index 000000000..555075751
--- /dev/null
+++ b/internal/target/generic_s3_target.go
@@ -0,0 +1,20 @@
+package target
+
+type GenericS3TargetOptions struct {
+	AWSS3TargetOptions
+	Endpoint string `json:"endpoint"`
+}
+
+func (GenericS3TargetOptions) isTargetOptions() {}
+
+func NewGenericS3Target(options *GenericS3TargetOptions) *Target {
+	return newTarget("org.osbuild.generic.s3", options)
+}
+
+type GenericS3TargetResultOptions AWSS3TargetResultOptions
+
+func (GenericS3TargetResultOptions) isTargetResultOptions() {}
+
+func NewGenericS3TargetResult(options *GenericS3TargetResultOptions) *TargetResult {
+	return newTargetResult("org.osbuild.generic.s3", options)
+}
diff --git a/internal/target/target.go b/internal/target/target.go
index fccc4c696..ace383c8a 100644
--- a/internal/target/target.go
+++ b/internal/target/target.go
@@ -83,6 +83,8 @@ func UnmarshalTargetOptions(targetName string, rawOptions json.RawMessage) (Targ
 		options = new(VMWareTargetOptions)
 	case "org.osbuild.oci":
 		options = new(OCITargetOptions)
+	case "org.osbuild.generic.s3":
+		options = new(GenericS3TargetOptions)
 	default:
 		return nil, errors.New("unexpected target name")
 	}
diff --git a/internal/target/targetresult.go b/internal/target/targetresult.go
index 737c93ecd..e3b29277c 100644
--- a/internal/target/targetresult.go
+++ b/internal/target/targetresult.go
@@ -55,8 +55,10 @@ func UnmarshalTargetResultOptions(trName string, rawOptions json.RawMessage) (Ta
 		options = new(AzureImageTargetResultOptions)
 	case "org.osbuild.oci":
 		options = new(OCITargetResultOptions)
+	case "org.osbuild.generic.s3":
+		options = new(GenericS3TargetResultOptions)
 	default:
-		return nil, fmt.Errorf("Unexpected target result name: %s", trName)
+		return nil, fmt.Errorf("unexpected target result name: %s", trName)
 	}
 	err := json.Unmarshal(rawOptions, options)
 
diff --git a/internal/weldr/upload.go b/internal/weldr/upload.go
index 226dbe05e..84a51ac64 100644
--- a/internal/weldr/upload.go
+++ b/internal/weldr/upload.go
@@ -36,6 +36,17 @@ type awsUploadSettings struct {
 
 func (awsUploadSettings) isUploadSettings() {}
 
+type awsS3UploadSettings struct {
+	Region          string `json:"region"`
+	AccessKeyID     string `json:"accessKeyID,omitempty"`
+	SecretAccessKey string `json:"secretAccessKey,omitempty"`
+	SessionToken    string `json:"sessionToken,omitempty"`
+	Bucket          string `json:"bucket"`
+	Key             string `json:"key"`
+}
+
+func (awsS3UploadSettings) isUploadSettings() {}
+
 type azureUploadSettings struct {
 	StorageAccount   string `json:"storageAccount,omitempty"`
 	StorageAccessKey string `json:"storageAccessKey,omitempty"`
@@ -68,6 +79,13 @@ type ociUploadSettings struct {
 
 func (ociUploadSettings) isUploadSettings() {}
 
+type genericS3UploadSettings struct {
+	awsS3UploadSettings
+	Endpoint string `json:"endpoint"`
+}
+
+func (genericS3UploadSettings) isUploadSettings() {}
+
 type uploadRequest struct {
 	Provider  string         `json:"provider"`
 	ImageName string         `json:"image_name"`
@@ -93,10 +111,14 @@ func (u *uploadRequest) UnmarshalJSON(data []byte) error {
 		settings = new(azureUploadSettings)
 	case "aws":
 		settings = new(awsUploadSettings)
+	case "aws.s3":
+		settings = new(awsS3UploadSettings)
 	case "vmware":
 		settings = new(vmwareUploadSettings)
 	case "oci":
 		settings = new(ociUploadSettings)
+	case "generic.s3":
+		settings = new(genericS3UploadSettings)
 	default:
 		return errors.New("unexpected provider name")
 	}
@@ -167,6 +189,27 @@ func targetsToUploadResponses(targets []*target.Target, state ComposeState) []up
 				// Username and Password are intentionally not included.
 			}
 			uploads = append(uploads, upload)
+		case *target.AWSS3TargetOptions:
+			upload.ProviderName = "aws.s3"
+			upload.Settings = &awsS3UploadSettings{
+				Region: options.Region,
+				Bucket: options.Bucket,
+				Key:    options.Key,
+				// AccessKeyID and SecretAccessKey are intentionally not included.
+			}
+			uploads = append(uploads, upload)
+		case *target.GenericS3TargetOptions:
+			upload.ProviderName = "generic.s3"
+			upload.Settings = &genericS3UploadSettings{
+				awsS3UploadSettings: awsS3UploadSettings{
+					Region: options.Region,
+					Bucket: options.Bucket,
+					Key:    options.Key,
+					// AccessKeyID and SecretAccessKey are intentionally not included.
+				},
+				Endpoint: options.Endpoint,
+			}
+			uploads = append(uploads, upload)
 		}
 	}
 
@@ -193,6 +236,17 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Bucket:          options.Bucket,
 			Key:             options.Key,
 		}
+	case *awsS3UploadSettings:
+		t.Name = "org.osbuild.aws.s3"
+		t.Options = &target.AWSS3TargetOptions{
+			Filename:        imageType.Filename(),
+			Region:          options.Region,
+			AccessKeyID:     options.AccessKeyID,
+			SecretAccessKey: options.SecretAccessKey,
+			SessionToken:    options.SessionToken,
+			Bucket:          options.Bucket,
+			Key:             options.Key,
+		}
 	case *azureUploadSettings:
 		t.Name = "org.osbuild.azure"
 		t.Options = &target.AzureTargetOptions{
@@ -225,6 +279,20 @@ func uploadRequestToTarget(u uploadRequest, imageType distro.ImageType) *target.
 			Namespace:   options.Namespace,
 			Compartment: options.Compartment,
 		}
+	case *genericS3UploadSettings:
+		t.Name = "org.osbuild.generic.s3"
+		t.Options = &target.GenericS3TargetOptions{
+			AWSS3TargetOptions: target.AWSS3TargetOptions{
+				Filename:        imageType.Filename(),
+				Region:          options.Region,
+				AccessKeyID:     options.AccessKeyID,
+				SecretAccessKey: options.SecretAccessKey,
+				SessionToken:    options.SessionToken,
+				Bucket:          options.Bucket,
+				Key:             options.Key,
+			},
+			Endpoint: options.Endpoint,
+		}
 	}
 
 	return &t
diff --git a/osbuild-composer.spec b/osbuild-composer.spec
index a6b024cb4..da31543aa 100644
--- a/osbuild-composer.spec
+++ b/osbuild-composer.spec
@@ -228,6 +228,7 @@ install -m 0755 -vp tools/run-koji-container.sh                    %{buildroot}%
 install -m 0755 -vp tools/koji-compose.py                          %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/koji-compose-v2.py                       %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/libvirt_test.sh                          %{buildroot}%{_libexecdir}/osbuild-composer-test/
+install -m 0755 -vp tools/s3_test.sh                               %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/set-env-variables.sh                     %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vp tools/test-case-generators/generate-test-cases %{buildroot}%{_libexecdir}/osbuild-composer-test/
 install -m 0755 -vd                                                %{buildroot}%{_libexecdir}/tests/osbuild-composer
diff --git a/vendor/github.com/google/go-cmp/cmp/compare.go b/vendor/github.com/google/go-cmp/cmp/compare.go
index 86d0903b8..2a5446762 100644
--- a/vendor/github.com/google/go-cmp/cmp/compare.go
+++ b/vendor/github.com/google/go-cmp/cmp/compare.go
@@ -36,7 +36,6 @@ import (
 	"strings"
 
 	"github.com/google/go-cmp/cmp/internal/diff"
-	"github.com/google/go-cmp/cmp/internal/flags"
 	"github.com/google/go-cmp/cmp/internal/function"
 	"github.com/google/go-cmp/cmp/internal/value"
 )
@@ -319,7 +318,6 @@ func (s *state) tryMethod(t reflect.Type, vx, vy reflect.Value) bool {
 }
 
 func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value {
-	v = sanitizeValue(v, f.Type().In(0))
 	if !s.dynChecker.Next() {
 		return f.Call([]reflect.Value{v})[0]
 	}
@@ -343,8 +341,6 @@ func (s *state) callTRFunc(f, v reflect.Value, step Transform) reflect.Value {
 }
 
 func (s *state) callTTBFunc(f, x, y reflect.Value) bool {
-	x = sanitizeValue(x, f.Type().In(0))
-	y = sanitizeValue(y, f.Type().In(1))
 	if !s.dynChecker.Next() {
 		return f.Call([]reflect.Value{x, y})[0].Bool()
 	}
@@ -372,19 +368,6 @@ func detectRaces(c chan<- reflect.Value, f reflect.Value, vs ...reflect.Value) {
 	ret = f.Call(vs)[0]
 }
 
-// sanitizeValue converts nil interfaces of type T to those of type R,
-// assuming that T is assignable to R.
-// Otherwise, it returns the input value as is.
-func sanitizeValue(v reflect.Value, t reflect.Type) reflect.Value {
-	// TODO(≥go1.10): Workaround for reflect bug (https://golang.org/issue/22143).
-	if !flags.AtLeastGo110 {
-		if v.Kind() == reflect.Interface && v.IsNil() && v.Type() != t {
-			return reflect.New(t).Elem()
-		}
-	}
-	return v
-}
-
 func (s *state) compareStruct(t reflect.Type, vx, vy reflect.Value) {
 	var addr bool
 	var vax, vay reflect.Value // Addressable versions of vx and vy
diff --git a/vendor/github.com/google/go-cmp/cmp/export_panic.go b/vendor/github.com/google/go-cmp/cmp/export_panic.go
index 5ff0b4218..ae851fe53 100644
--- a/vendor/github.com/google/go-cmp/cmp/export_panic.go
+++ b/vendor/github.com/google/go-cmp/cmp/export_panic.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build purego
 // +build purego
 
 package cmp
diff --git a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go b/vendor/github.com/google/go-cmp/cmp/export_unsafe.go
index 21eb54858..e2c0f74e8 100644
--- a/vendor/github.com/google/go-cmp/cmp/export_unsafe.go
+++ b/vendor/github.com/google/go-cmp/cmp/export_unsafe.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build !purego
 // +build !purego
 
 package cmp
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go
index 1daaaacc5..36062a604 100644
--- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go
+++ b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_disable.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build !cmp_debug
 // +build !cmp_debug
 
 package diff
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go
index 4b91dbcac..a3b97a1ad 100644
--- a/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go
+++ b/vendor/github.com/google/go-cmp/cmp/internal/diff/debug_enable.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build cmp_debug
 // +build cmp_debug
 
 package diff
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_legacy.go b/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_legacy.go
deleted file mode 100644
index 82d1d7fbf..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_legacy.go
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// +build !go1.10
-
-package flags
-
-// AtLeastGo110 reports whether the Go toolchain is at least Go 1.10.
-const AtLeastGo110 = false
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_recent.go b/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_recent.go
deleted file mode 100644
index 8646f0529..000000000
--- a/vendor/github.com/google/go-cmp/cmp/internal/flags/toolchain_recent.go
+++ /dev/null
@@ -1,10 +0,0 @@
-// Copyright 2019, The Go Authors. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-// +build go1.10
-
-package flags
-
-// AtLeastGo110 reports whether the Go toolchain is at least Go 1.10.
-const AtLeastGo110 = true
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go
index b6c12cefb..7b498bb2c 100644
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/name.go
+++ b/vendor/github.com/google/go-cmp/cmp/internal/value/name.go
@@ -9,6 +9,8 @@ import (
 	"strconv"
 )
 
+var anyType = reflect.TypeOf((*interface{})(nil)).Elem()
+
 // TypeString is nearly identical to reflect.Type.String,
 // but has an additional option to specify that full type names be used.
 func TypeString(t reflect.Type, qualified bool) string {
@@ -20,6 +22,11 @@ func appendTypeName(b []byte, t reflect.Type, qualified, elideFunc bool) []byte
 	// of the same name and within the same package,
 	// but declared within the namespace of different functions.
 
+	// Use the "any" alias instead of "interface{}" for better readability.
+	if t == anyType {
+		return append(b, "any"...)
+	}
+
 	// Named type.
 	if t.Name() != "" {
 		if qualified && t.PkgPath() != "" {
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go
index 44f4a5afd..1a71bfcbd 100644
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go
+++ b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_purego.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build purego
 // +build purego
 
 package value
diff --git a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go
index a605953d4..16e6860af 100644
--- a/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go
+++ b/vendor/github.com/google/go-cmp/cmp/internal/value/pointer_unsafe.go
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
+//go:build !purego
 // +build !purego
 
 package value
diff --git a/vendor/github.com/google/go-cmp/cmp/path.go b/vendor/github.com/google/go-cmp/cmp/path.go
index f01eff318..c71003463 100644
--- a/vendor/github.com/google/go-cmp/cmp/path.go
+++ b/vendor/github.com/google/go-cmp/cmp/path.go
@@ -178,7 +178,7 @@ type structField struct {
 	unexported bool
 	mayForce   bool                // Forcibly allow visibility
 	paddr      bool                // Was parent addressable?
-	pvx, pvy   reflect.Value       // Parent values (always addressible)
+	pvx, pvy   reflect.Value       // Parent values (always addressable)
 	field      reflect.StructField // Field information
 }
 
diff --git a/vendor/github.com/google/go-cmp/cmp/report_reflect.go b/vendor/github.com/google/go-cmp/cmp/report_reflect.go
index 33f03577f..76c04fdbd 100644
--- a/vendor/github.com/google/go-cmp/cmp/report_reflect.go
+++ b/vendor/github.com/google/go-cmp/cmp/report_reflect.go
@@ -207,9 +207,10 @@ func (opts formatOptions) FormatValue(v reflect.Value, parentKind reflect.Kind,
 		// Check whether this is a []byte of text data.
 		if t.Elem() == reflect.TypeOf(byte(0)) {
 			b := v.Bytes()
-			isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) && unicode.IsSpace(r) }
+			isPrintSpace := func(r rune) bool { return unicode.IsPrint(r) || unicode.IsSpace(r) }
 			if len(b) > 0 && utf8.Valid(b) && len(bytes.TrimFunc(b, isPrintSpace)) == 0 {
 				out = opts.formatString("", string(b))
+				skipType = true
 				return opts.WithTypeMode(emitType).FormatType(t, out)
 			}
 		}
diff --git a/vendor/github.com/google/go-cmp/cmp/report_slices.go b/vendor/github.com/google/go-cmp/cmp/report_slices.go
index 2ad3bc85b..68b5c1ae1 100644
--- a/vendor/github.com/google/go-cmp/cmp/report_slices.go
+++ b/vendor/github.com/google/go-cmp/cmp/report_slices.go
@@ -80,7 +80,7 @@ func (opts formatOptions) CanFormatDiffSlice(v *valueNode) bool {
 	}
 
 	// Use specialized string diffing for longer slices or strings.
-	const minLength = 64
+	const minLength = 32
 	return vx.Len() >= minLength && vy.Len() >= minLength
 }
 
@@ -563,10 +563,10 @@ func cleanupSurroundingIdentical(groups []diffStats, eq func(i, j int) bool) []d
 		nx := ds.NumIdentical + ds.NumRemoved + ds.NumModified
 		ny := ds.NumIdentical + ds.NumInserted + ds.NumModified
 		var numLeadingIdentical, numTrailingIdentical int
-		for i := 0; i < nx && i < ny && eq(ix+i, iy+i); i++ {
+		for j := 0; j < nx && j < ny && eq(ix+j, iy+j); j++ {
 			numLeadingIdentical++
 		}
-		for i := 0; i < nx && i < ny && eq(ix+nx-1-i, iy+ny-1-i); i++ {
+		for j := 0; j < nx && j < ny && eq(ix+nx-1-j, iy+ny-1-j); j++ {
 			numTrailingIdentical++
 		}
 		if numIdentical := numLeadingIdentical + numTrailingIdentical; numIdentical > 0 {
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 4d29c4acb..393fa8286 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -246,7 +246,7 @@ github.com/golang/protobuf/ptypes/duration
 github.com/golang/protobuf/ptypes/struct
 github.com/golang/protobuf/ptypes/timestamp
 github.com/golang/protobuf/ptypes/wrappers
-# github.com/google/go-cmp v0.5.6
+# github.com/google/go-cmp v0.5.7
 ## explicit
 github.com/google/go-cmp/cmp
 github.com/google/go-cmp/cmp/internal/diff


Test patch:
diff --git a/internal/osbuild2/qemu_stage_test.go b/internal/osbuild2/qemu_stage_test.go
index 996993a24..7921115cc 100644
--- a/internal/osbuild2/qemu_stage_test.go
+++ b/internal/osbuild2/qemu_stage_test.go
@@ -1,6 +1,7 @@
 package osbuild2
 
 import (
+	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/assert"
@@ -9,15 +10,21 @@ import (
 func TestNewQemuStage(t *testing.T) {
 
 	formatOptionsList := []QEMUFormatOptions{
-		Qcow2Options{
-			Type:   "qcow2",
+		QCOW2Options{
+			Type:   QEMUFormatQCOW2,
 			Compat: "0.10",
 		},
+		VDIOptions{
+			Type: QEMUFormatVDI,
+		},
 		VPCOptions{
-			Type: "vpc",
+			Type: QEMUFormatVPC,
 		},
 		VMDKOptions{
-			Type: "vmdk",
+			Type: QEMUFormatVMDK,
+		},
+		VHDXOptions{
+			Type: QEMUFormatVHDX,
 		},
 	}
 
@@ -46,3 +53,163 @@ func TestNewQemuStage(t *testing.T) {
 		assert.Equal(t, expectedStage, actualStage)
 	}
 }
+
+func TestNewQEMUStageOptions(t *testing.T) {
+	tests := []struct {
+		Filename        string
+		Format          QEMUFormat
+		FormatOptions   QEMUFormatOptions
+		ExpectedOptions *QEMUStageOptions
+		Error           bool
+	}{
+		{
+			Filename: "image.qcow2",
+			Format:   QEMUFormatQCOW2,
+			FormatOptions: QCOW2Options{
+				Compat: "1.1",
+			},
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.qcow2",
+				Format: QCOW2Options{
+					Type:   QEMUFormatQCOW2,
+					Compat: "1.1",
+				},
+			},
+		},
+		{
+			Filename: "image.qcow2",
+			Format:   QEMUFormatQCOW2,
+			FormatOptions: QCOW2Options{
+				Type: QEMUFormatQCOW2,
+			},
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.qcow2",
+				Format: QCOW2Options{
+					Type: QEMUFormatQCOW2,
+				},
+			},
+		},
+		{
+			Filename:      "image.qcow2",
+			Format:        QEMUFormatQCOW2,
+			FormatOptions: QCOW2Options{},
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.qcow2",
+				Format: QCOW2Options{
+					Type: QEMUFormatQCOW2,
+				},
+			},
+		},
+		{
+			Filename:      "image.qcow2",
+			Format:        QEMUFormatQCOW2,
+			FormatOptions: nil,
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.qcow2",
+				Format: QCOW2Options{
+					Type: QEMUFormatQCOW2,
+				},
+			},
+		},
+		{
+			Filename:      "image.vdi",
+			Format:        QEMUFormatVDI,
+			FormatOptions: nil,
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.vdi",
+				Format: VDIOptions{
+					Type: QEMUFormatVDI,
+				},
+			},
+		},
+		{
+			Filename:      "image.vpc",
+			Format:        QEMUFormatVPC,
+			FormatOptions: nil,
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.vpc",
+				Format: VPCOptions{
+					Type: QEMUFormatVPC,
+				},
+			},
+		},
+		{
+			Filename:      "image.vmdk",
+			Format:        QEMUFormatVMDK,
+			FormatOptions: nil,
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.vmdk",
+				Format: VMDKOptions{
+					Type: QEMUFormatVMDK,
+				},
+			},
+		},
+		{
+			Filename: "image.vmdk",
+			Format:   QEMUFormatVMDK,
+			FormatOptions: VMDKOptions{
+				Subformat: VMDKSubformatStreamOptimized,
+			},
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.vmdk",
+				Format: VMDKOptions{
+					Type:      QEMUFormatVMDK,
+					Subformat: VMDKSubformatStreamOptimized,
+				},
+			},
+		},
+		{
+			Filename:      "image.vhdx",
+			Format:        QEMUFormatVHDX,
+			FormatOptions: nil,
+			ExpectedOptions: &QEMUStageOptions{
+				Filename: "image.vhdx",
+				Format: VHDXOptions{
+					Type: QEMUFormatVHDX,
+				},
+			},
+		},
+		// mismatch between format and format options type
+		{
+			Filename:      "image.qcow2",
+			Format:        QEMUFormatQCOW2,
+			FormatOptions: VMDKOptions{},
+			Error:         true,
+		},
+		// mismatch between format and format options type
+		{
+			Filename: "image.qcow2",
+			Format:   QEMUFormatQCOW2,
+			FormatOptions: VMDKOptions{
+				Type: QEMUFormatQCOW2,
+			},
+			Error: true,
+		},
+		// mismatch between format and format options type
+		{
+			Filename: "image.qcow2",
+			Format:   QEMUFormatQCOW2,
+			FormatOptions: VMDKOptions{
+				Type: QEMUFormatVMDK,
+			},
+			Error: true,
+		},
+		// unknown format
+		{
+			Filename:      "image.qcow2",
+			Format:        "",
+			FormatOptions: nil,
+			Error:         true,
+		},
+	}
+	for idx, test := range tests {
+		t.Run(fmt.Sprintf("test-(%d/%d)", idx, len(tests)), func(t *testing.T) {
+			if test.Error {
+				assert.Panics(t, func() { NewQEMUStageOptions(test.Filename, test.Format, test.FormatOptions) })
+			} else {
+				stageOptions := NewQEMUStageOptions(test.Filename, test.Format, test.FormatOptions)
+				assert.EqualValues(t, test.ExpectedOptions, stageOptions)
+			}
+		})
+	}
+}
diff --git a/test/cases/api.sh b/test/cases/api.sh
index bf67764e7..e1801b8bc 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -166,7 +166,7 @@ function checkEnvAzure() {
 
 # Check that needed variables are set to register to RHSM (RHEL only)
 function checkEnvSubscription() {
-  printenv API_TEST_SUBSCRIPTION_ORG_ID API_TEST_SUBSCRIPTION_ACTIVATION_KEY > /dev/null
+  printenv API_TEST_SUBSCRIPTION_ORG_ID API_TEST_SUBSCRIPTION_ACTIVATION_KEY_V2 > /dev/null
 }
 
 function checkEnvVSphere() {
@@ -541,7 +541,7 @@ if [[ "$ID" == "rhel" ]]; then
 ,
     "subscription": {
       "organization": "${API_TEST_SUBSCRIPTION_ORG_ID:-}",
-      "activation_key": "${API_TEST_SUBSCRIPTION_ACTIVATION_KEY:-}",
+      "activation_key": "${API_TEST_SUBSCRIPTION_ACTIVATION_KEY_V2:-}",
       "base_url": "https://cdn.redhat.com/",
       "server_url": "subscription.rhsm.redhat.com",
       "insights": true
diff --git a/test/cases/aws_s3.sh b/test/cases/aws_s3.sh
new file mode 100755
index 000000000..82dd9ab09
--- /dev/null
+++ b/test/cases/aws_s3.sh
@@ -0,0 +1,72 @@
+#!/bin/bash
+set -euo pipefail
+
+source /usr/libexec/osbuild-composer-test/set-env-variables.sh
+
+# Container image used for cloud provider CLI tools
+CONTAINER_IMAGE_CLOUD_TOOLS="quay.io/osbuild/cloud-tools:latest"
+
+# Provision the software under test.
+/usr/libexec/osbuild-composer-test/provision.sh
+
+# Check available container runtime
+if which podman 2>/dev/null >&2; then
+    CONTAINER_RUNTIME=podman
+elif which docker 2>/dev/null >&2; then
+    CONTAINER_RUNTIME=docker
+else
+    echo No container runtime found, install podman or docker.
+    exit 2
+fi
+
+TEMPDIR=$(mktemp -d)
+function cleanup() {
+    sudo rm -rf "$TEMPDIR"
+}
+trap cleanup EXIT
+
+# Generate a string, which can be used as a predictable resource name,
+# especially when running the test in CI where we may need to clean up
+# resources in case the test unexpectedly fails or is canceled
+CI="${CI:-false}"
+if [[ "$CI" == true ]]; then
+  # in CI, imitate GenerateCIArtifactName() from internal/test/helpers.go
+  TEST_ID="$DISTRO_CODE-$ARCH-$CI_COMMIT_BRANCH-$CI_BUILD_ID"
+else
+  # if not running in Jenkins, generate ID not relying on specific env variables
+  TEST_ID=$(uuidgen);
+fi
+
+# Set up temporary files.
+AWS_S3_PROVIDER_CONFIG=${TEMPDIR}/aws.toml
+
+# We need awscli to talk to AWS.
+if ! hash aws; then
+    echo "Using 'awscli' from a container"
+    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_IMAGE_CLOUD_TOOLS}
+
+    AWS_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+        -e AWS_ACCESS_KEY_ID=${V2_AWS_ACCESS_KEY_ID} \
+        -e AWS_SECRET_ACCESS_KEY=${V2_AWS_SECRET_ACCESS_KEY} \
+        ${CONTAINER_IMAGE_CLOUD_TOOLS} aws --region $AWS_REGION"
+else
+    echo "Using pre-installed 'aws' from the system"
+    AWS_CMD="aws --region $AWS_REGION"
+fi
+$AWS_CMD --version
+
+# Write an AWS TOML file
+tee "$AWS_S3_PROVIDER_CONFIG" > /dev/null << EOF
+provider = "aws.s3"
+
+[settings]
+accessKeyID = "${V2_AWS_ACCESS_KEY_ID}"
+secretAccessKey = "${V2_AWS_SECRET_ACCESS_KEY}"
+bucket = "${AWS_BUCKET}"
+region = "${AWS_REGION}"
+key = "${TEST_ID}"
+EOF
+
+IMAGE_OBJECT_KEY="${AWS_BUCKET}/${TEST_ID}-disk.qcow2"
+
+/usr/libexec/osbuild-composer-test/s3_test.sh "${TEST_ID}" "${AWS_S3_PROVIDER_CONFIG}" "${AWS_CMD} s3 ls ${IMAGE_OBJECT_KEY}" "${AWS_CMD} s3 presign ${IMAGE_OBJECT_KEY}" "${AWS_CMD} s3 rm s3://${IMAGE_OBJECT_KEY}"
diff --git a/test/cases/generic_s3.sh b/test/cases/generic_s3.sh
new file mode 100755
index 000000000..48b3c0f0a
--- /dev/null
+++ b/test/cases/generic_s3.sh
@@ -0,0 +1,98 @@
+#!/bin/bash
+set -euo pipefail
+
+source /usr/libexec/osbuild-composer-test/set-env-variables.sh
+
+# Container images for MinIO Server and Client
+CONTAINER_MINIO_CLIENT="quay.io/minio/mc:latest"
+CONTAINER_MINIO_SERVER="quay.io/minio/minio:latest"
+
+# Provision the software under test.
+/usr/libexec/osbuild-composer-test/provision.sh
+
+# Check available container runtime
+if which podman 2>/dev/null >&2; then
+    CONTAINER_RUNTIME=podman
+elif which docker 2>/dev/null >&2; then
+    CONTAINER_RUNTIME=docker
+else
+    echo No container runtime found, install podman or docker.
+    exit 2
+fi
+
+TEMPDIR=$(mktemp -d)
+function cleanup() {
+    sudo rm -rf "$TEMPDIR"
+}
+trap cleanup EXIT
+
+# Generate a string, which can be used as a predictable resource name,
+# especially when running the test in CI where we may need to clean up
+# resources in case the test unexpectedly fails or is canceled
+CI="${CI:-false}"
+if [[ "$CI" == true ]]; then
+  # in CI, imitate GenerateCIArtifactName() from internal/test/helpers.go
+  TEST_ID="$DISTRO_CODE-$ARCH-$CI_COMMIT_BRANCH-$CI_BUILD_ID"
+else
+  # if not running in Jenkins, generate ID not relying on specific env variables
+  TEST_ID=$(uuidgen);
+fi
+
+# Set up temporary files.
+MINIO_CONFIG_DIR=${TEMPDIR}/minio-config
+MINIO_PROVIDER_CONFIG=${TEMPDIR}/minio.toml
+
+# We need MinIO Client to talk to the MinIO Server.
+if ! hash mc; then
+    echo "Using 'mc' from a container"
+    sudo ${CONTAINER_RUNTIME} pull ${CONTAINER_MINIO_CLIENT}
+
+    MC_CMD="sudo ${CONTAINER_RUNTIME} run --rm \
+        -v ${MINIO_CONFIG_DIR}:${MINIO_CONFIG_DIR}:Z \
+        --network=host \
+        ${CONTAINER_MINIO_CLIENT} --config-dir=${MINIO_CONFIG_DIR}"
+else
+    echo "Using pre-installed 'mc' from the system"
+    MC_CMD="mc --config-dir=${MINIO_CONFIG_DIR}"
+fi
+mkdir "${MINIO_CONFIG_DIR}"
+$MC_CMD --version
+
+MINIO_CONTAINER_NAME="minio-server"
+MINIO_ENDPOINT="http://localhost:9000"
+MINIO_ROOT_USER="X29DU5Q6C5NKDQ8PLGVT"
+MINIO_ROOT_PASSWORD=$(date +%s | sha256sum | base64 | head -c 32 ; echo)
+MINIO_SERVER_ALIAS=local
+MINIO_BUCKET="ci-test"
+MINIO_REGION="us-east-1"
+# Write an AWS TOML file
+tee "$MINIO_PROVIDER_CONFIG" > /dev/null << EOF
+provider = "generic.s3"
+
+[settings]
+endpoint = "${MINIO_ENDPOINT}"
+accessKeyID = "${MINIO_ROOT_USER}"
+secretAccessKey = "${MINIO_ROOT_PASSWORD}"
+bucket = "${MINIO_BUCKET}"
+region = "${MINIO_REGION}"
+key = "${TEST_ID}"
+EOF
+
+# Start the MinIO Server
+${CONTAINER_RUNTIME} run --rm -d \
+    --name ${MINIO_CONTAINER_NAME} \
+    -p 9000:9000 \
+    -e MINIO_BROWSER=off \
+    -e MINIO_ROOT_USER="${MINIO_ROOT_USER}" \
+    -e MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD}" \
+    ${CONTAINER_MINIO_SERVER} server /data
+# Kill the server once we're done
+trap '${CONTAINER_RUNTIME} kill ${MINIO_CONTAINER_NAME}' EXIT
+
+# Configure the local server
+${MC_CMD} alias set ${MINIO_SERVER_ALIAS} ${MINIO_ENDPOINT} ${MINIO_ROOT_USER} "${MINIO_ROOT_PASSWORD}"
+# Create the bucket
+${MC_CMD} mb ${MINIO_SERVER_ALIAS}/${MINIO_BUCKET}
+
+IMAGE_OBJECT_KEY="${MINIO_SERVER_ALIAS}/${MINIO_BUCKET}/${TEST_ID}-disk.qcow2"
+/usr/libexec/osbuild-composer-test/s3_test.sh "${TEST_ID}" "${MINIO_PROVIDER_CONFIG}" "${MC_CMD} ls ${IMAGE_OBJECT_KEY}" "${MC_CMD} --json share download ${IMAGE_OBJECT_KEY} | jq .share | tr -d '\"'"
diff --git a/tools/libvirt_test.sh b/tools/libvirt_test.sh
index 1cbdc7f6d..ef95aba92 100755
--- a/tools/libvirt_test.sh
+++ b/tools/libvirt_test.sh
@@ -17,6 +17,8 @@ IMAGE_TYPE=${1:-qcow2}
 # Take the boot type passed to the script or use BIOS by default if nothing
 # was passed.
 BOOT_TYPE=${2:-bios}
+# Take the image from the url passes to the script or build it by default if nothing
+LIBVIRT_IMAGE_URL=${3:-""}
 
 # Select the file extension based on the image that we are building.
 IMAGE_EXTENSION=$IMAGE_TYPE
@@ -131,82 +133,90 @@ get_compose_metadata () {
     sudo cat "${COMPOSE_ID}".json | jq -M '.' | tee "$METADATA_FILE" > /dev/null
 }
 
-# Write a basic blueprint for our image.
-tee "$BLUEPRINT_FILE" > /dev/null << EOF
+# Current $PWD is inside /tmp, there may not be enough space for an image.
+# Let's use a bigger temporary directory for this operation.
+BIG_TEMP_DIR=/var/lib/osbuild-composer-tests
+sudo rm -rf "${BIG_TEMP_DIR}" || true
+sudo mkdir "${BIG_TEMP_DIR}"
+
+if [ -z "${LIBVIRT_IMAGE_URL}" ]; then
+    # Write a basic blueprint for our image.
+    tee "$BLUEPRINT_FILE" > /dev/null << EOF
 name = "bp"
 description = "A base system"
 version = "0.0.1"
 EOF
 
-# Prepare the blueprint for the compose.
-greenprint "📋 Preparing blueprint"
-sudo composer-cli blueprints push "$BLUEPRINT_FILE"
-sudo composer-cli blueprints depsolve bp
-
-# Get worker unit file so we can watch the journal.
-WORKER_UNIT=$(sudo systemctl list-units | grep -o -E "osbuild.*worker.*\.service")
-sudo journalctl -af -n 1 -u "${WORKER_UNIT}" &
-WORKER_JOURNAL_PID=$!
-# Stop watching the worker journal when exiting.
-trap 'sudo pkill -P ${WORKER_JOURNAL_PID}' EXIT
-
-# Start the compose
-greenprint "🚀 Starting compose"
-sudo composer-cli --json compose start bp "$IMAGE_TYPE" | tee "$COMPOSE_START"
-if rpm -q --quiet weldr-client; then
-    COMPOSE_ID=$(jq -r '.body.build_id' "$COMPOSE_START")
-else
-    COMPOSE_ID=$(jq -r '.build_id' "$COMPOSE_START")
-fi
-
-# Wait for the compose to finish.
-greenprint "⏱ Waiting for compose to finish: ${COMPOSE_ID}"
-while true; do
-    sudo composer-cli --json compose info "${COMPOSE_ID}" | tee "$COMPOSE_INFO" > /dev/null
+    # Prepare the blueprint for the compose.
+    greenprint "📋 Preparing blueprint"
+    sudo composer-cli blueprints push "$BLUEPRINT_FILE"
+    sudo composer-cli blueprints depsolve bp
+
+    # Get worker unit file so we can watch the journal.
+    WORKER_UNIT=$(sudo systemctl list-units | grep -o -E "osbuild.*worker.*\.service")
+    sudo journalctl -af -n 1 -u "${WORKER_UNIT}" &
+    WORKER_JOURNAL_PID=$!
+    # Stop watching the worker journal when exiting.
+    trap 'sudo pkill -P ${WORKER_JOURNAL_PID}' EXIT
+
+    # Start the compose
+    greenprint "🚀 Starting compose"
+    sudo composer-cli --json compose start bp "$IMAGE_TYPE" | tee "$COMPOSE_START"
     if rpm -q --quiet weldr-client; then
-        COMPOSE_STATUS=$(jq -r '.body.queue_status' "$COMPOSE_INFO")
+        COMPOSE_ID=$(jq -r '.body.build_id' "$COMPOSE_START")
     else
-        COMPOSE_STATUS=$(jq -r '.queue_status' "$COMPOSE_INFO")
+        COMPOSE_ID=$(jq -r '.build_id' "$COMPOSE_START")
     fi
 
-    # Is the compose finished?
-    if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then
-        break
+    # Wait for the compose to finish.
+    greenprint "⏱ Waiting for compose to finish: ${COMPOSE_ID}"
+    while true; do
+        sudo composer-cli --json compose info "${COMPOSE_ID}" | tee "$COMPOSE_INFO" > /dev/null
+        if rpm -q --quiet weldr-client; then
+            COMPOSE_STATUS=$(jq -r '.body.queue_status' "$COMPOSE_INFO")
+        else
+            COMPOSE_STATUS=$(jq -r '.queue_status' "$COMPOSE_INFO")
+        fi
+
+        # Is the compose finished?
+        if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then
+            break
+        fi
+
+        # Wait 30 seconds and try again.
+        sleep 5
+    done
+
+    # Capture the compose logs from osbuild.
+    greenprint "💬 Getting compose log and metadata"
+    get_compose_log "$COMPOSE_ID"
+    get_compose_metadata "$COMPOSE_ID"
+
+    # Kill the journal monitor immediately and remove the trap
+    sudo pkill -P ${WORKER_JOURNAL_PID}
+    trap - EXIT
+
+    # Did the compose finish with success?
+    if [[ $COMPOSE_STATUS != FINISHED ]]; then
+        echo "Something went wrong with the compose. 😢"
+        exit 1
     fi
 
-    # Wait 30 seconds and try again.
-    sleep 5
-done
-
-# Capture the compose logs from osbuild.
-greenprint "💬 Getting compose log and metadata"
-get_compose_log "$COMPOSE_ID"
-get_compose_metadata "$COMPOSE_ID"
-
-# Kill the journal monitor immediately and remove the trap
-sudo pkill -P ${WORKER_JOURNAL_PID}
-trap - EXIT
+    # Download the image.
+    greenprint "📥 Downloading the image"
 
-# Did the compose finish with success?
-if [[ $COMPOSE_STATUS != FINISHED ]]; then
-    echo "Something went wrong with the compose. 😢"
-    exit 1
-fi
-
-# Download the image.
-greenprint "📥 Downloading the image"
-
-# Current $PWD is inside /tmp, there may not be enough space for an image.
-# Let's use a bigger temporary directory for this operation.
-BIG_TEMP_DIR=/var/lib/osbuild-composer-tests
-sudo rm -rf "${BIG_TEMP_DIR}" || true
-sudo mkdir "${BIG_TEMP_DIR}"
-pushd "${BIG_TEMP_DIR}"
-    sudo composer-cli compose image "${COMPOSE_ID}" > /dev/null
-    IMAGE_FILENAME=$(basename "$(find . -maxdepth 1 -type f -name "*.${IMAGE_EXTENSION}")")
+    pushd "${BIG_TEMP_DIR}"
+        sudo composer-cli compose image "${COMPOSE_ID}" > /dev/null
+        IMAGE_FILENAME=$(basename "$(find . -maxdepth 1 -type f -name "*.${IMAGE_EXTENSION}")")
+        LIBVIRT_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}.${IMAGE_EXTENSION}
+        sudo mv "$IMAGE_FILENAME" "$LIBVIRT_IMAGE_PATH"
+    popd
+else
+    pushd "${BIG_TEMP_DIR}"
     LIBVIRT_IMAGE_PATH=/var/lib/libvirt/images/${IMAGE_KEY}.${IMAGE_EXTENSION}
-    sudo mv "$IMAGE_FILENAME" "$LIBVIRT_IMAGE_PATH"
-popd
+    sudo curl -o "${LIBVIRT_IMAGE_PATH}" "${LIBVIRT_IMAGE_URL}"
+    popd
+fi
 
 # Prepare cloud-init data.
 CLOUD_INIT_DIR=$(mktemp -d)
@@ -323,8 +333,10 @@ else
 fi
 sudo rm -f "$LIBVIRT_IMAGE_PATH" $CLOUD_INIT_PATH
 
-# Also delete the compose so we don't run out of disk space
-sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+if [ -z "${LIBVIRT_IMAGE_URL}" ]; then
+    # Also delete the compose so we don't run out of disk space
+    sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+fi
 
 # Use the return code of the smoke test to determine if we passed or failed.
 if [[ $RESULTS == 1 ]]; then
diff --git a/tools/s3_test.sh b/tools/s3_test.sh
new file mode 100755
index 000000000..34bd69bca
--- /dev/null
+++ b/tools/s3_test.sh
@@ -0,0 +1,145 @@
+#!/bin/bash
+set -euo pipefail
+
+source /usr/libexec/osbuild-composer-test/set-env-variables.sh
+
+TEST_ID=${1}
+S3_PROVIDER_CONFIG_FILE=${2}
+S3_CHECK_CMD=${3}
+S3_GET_URL_CMD=${4}
+S3_DELETE_CMD=${5:-""}
+
+# Colorful output.
+function greenprint {
+    echo -e "\033[1;32m[$(date -Isecond)] ${1}\033[0m"
+}
+
+function get_build_info() {
+    key="$1"
+    fname="$2"
+    if rpm -q --quiet weldr-client; then
+        key=".body${key}"
+    fi
+    jq -r "${key}" "${fname}"
+}
+
+TEMPDIR=$(mktemp -d)
+function cleanup() {
+    sudo rm -rf "$TEMPDIR"
+}
+trap cleanup EXIT
+
+# Jenkins sets WORKSPACE to the job workspace, but if this script runs
+# outside of Jenkins, we can set up a temporary directory instead.
+if [[ ${WORKSPACE:-empty} == empty ]]; then
+    WORKSPACE=$(mktemp -d)
+fi
+
+# Set up temporary files.
+BLUEPRINT_FILE=${TEMPDIR}/blueprint.toml
+BLUEPRINT_NAME=empty
+COMPOSE_START=${TEMPDIR}/compose-start-${TEST_ID}.json
+COMPOSE_INFO=${TEMPDIR}/compose-info-${TEST_ID}.json
+
+# Get the compose log.
+get_compose_log () {
+    COMPOSE_ID=$1
+    LOG_FILE=${WORKSPACE}/osbuild-${ID}-${VERSION_ID}-aws.log
+
+    # Download the logs.
+    sudo composer-cli compose log "$COMPOSE_ID" | tee "$LOG_FILE" > /dev/null
+}
+
+# Get the compose metadata.
+get_compose_metadata () {
+    COMPOSE_ID=$1
+    METADATA_FILE=${WORKSPACE}/osbuild-${ID}-${VERSION_ID}-aws.json
+
+    # Download the metadata.
+    sudo composer-cli compose metadata "$COMPOSE_ID" > /dev/null
+
+    # Find the tarball and extract it.
+    TARBALL=$(basename "$(find . -maxdepth 1 -type f -name "*-metadata.tar")")
+    sudo tar -xf "$TARBALL"
+    sudo rm -f "$TARBALL"
+
+    # Move the JSON file into place.
+    sudo cat "${COMPOSE_ID}".json | jq -M '.' | tee "$METADATA_FILE" > /dev/null
+}
+
+# Write a basic blueprint for our image.
+tee "$BLUEPRINT_FILE" > /dev/null << EOF
+name = "${BLUEPRINT_NAME}"
+description = "A base system with bash"
+version = "0.0.1"
+EOF
+
+# Prepare the blueprint for the compose.
+greenprint "📋 Preparing blueprint"
+sudo composer-cli blueprints push "$BLUEPRINT_FILE"
+sudo composer-cli blueprints depsolve ${BLUEPRINT_NAME}
+
+# Get worker unit file so we can watch the journal.
+WORKER_UNIT=$(sudo systemctl list-units | grep -o -E "osbuild.*worker.*\.service")
+sudo journalctl -af -n 1 -u "${WORKER_UNIT}" &
+WORKER_JOURNAL_PID=$!
+# Stop watching the worker journal when exiting.
+trap 'sudo pkill -P ${WORKER_JOURNAL_PID}' EXIT
+
+# Start the compose and upload to AWS.
+greenprint "🚀 Starting compose"
+sudo composer-cli --json compose start ${BLUEPRINT_NAME} qcow2 "$TEST_ID" "$S3_PROVIDER_CONFIG_FILE" | tee "$COMPOSE_START"
+COMPOSE_ID=$(get_build_info ".build_id" "$COMPOSE_START")
+
+# Wait for the compose to finish.
+greenprint "⏱ Waiting for compose to finish: ${COMPOSE_ID}"
+while true; do
+    sudo composer-cli --json compose info "${COMPOSE_ID}" | tee "$COMPOSE_INFO" > /dev/null
+    COMPOSE_STATUS=$(get_build_info ".queue_status" "$COMPOSE_INFO")
+
+    # Is the compose finished?
+    if [[ $COMPOSE_STATUS != RUNNING ]] && [[ $COMPOSE_STATUS != WAITING ]]; then
+        break
+    fi
+
+    # Wait 30 seconds and try again.
+    sleep 30
+done
+
+# Capture the compose logs from osbuild.
+greenprint "💬 Getting compose log and metadata"
+get_compose_log "$COMPOSE_ID"
+get_compose_metadata "$COMPOSE_ID"
+
+# Kill the journal monitor immediately and remove the trap
+sudo pkill -P ${WORKER_JOURNAL_PID}
+trap - EXIT
+
+# Did the compose finish with success?
+if [[ $COMPOSE_STATUS != FINISHED ]]; then
+    echo "Something went wrong with the compose. 😢"
+    exit 1
+fi
+
+# Delete the compose so we don't run out of disk space
+sudo composer-cli compose delete "${COMPOSE_ID}" > /dev/null
+
+# Find the image that we made in the AWS Bucket
+greenprint "🔍 Search for created image"
+if ! bash -c "${S3_CHECK_CMD}"; then
+    echo "Failed to find the image in the S3 Bucket"
+    exit 1
+fi
+
+function removeImageFromS3() {
+    bash -c "${S3_DELETE_CMD}"
+}
+if [ -n "${S3_DELETE_CMD}" ]; then
+    trap removeImageFromS3 EXIT
+fi
+
+# Generate a URL for the image
+QCOW2_IMAGE_URL=$(bash -c "${S3_GET_URL_CMD}")
+
+# Run the image on KVM
+/usr/libexec/osbuild-composer-test/libvirt_test.sh qcow2 bios "${QCOW2_IMAGE_URL}"