Repository: osbuild/osbuild-composer
Pull request #2471 (2022-03-22 20:00:54)

Support uploading to any S3 service via the WELDR API
This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [ ] adequate documentation informing people about the change such as
  - [ ] submit a PR for the [guides](https://github.com/osbuild/guides) repository if this PR changed any behavior described there: https://www.osbuild.org/guides/

<!--
Thanks for proposing a change to osbuild-composer!

Please don't remove the above check list. These are things that each pull
request must have before it is merged. It helps maintainers to not forget
anything.

If the reason for ticking any of the boxes is ambiguous, please add a short
note explaining why.

In addition, if this pull request fixes a downstream issue, please refer to
test/README.md and add these additional items:

- [ ] 1st commit of any `rhbz#` related PR contains bug reproducer; CI reports FAIL or
- [ ] PR contains automated tests for new functionality and
- [ ] QE has approved reproducer/new tests and
- [ ] Subsequent commits provide bug fixes without modifying the reproducer; CI reports PASS and
- [ ] QE approves this PR; RHBZ status is set to `MODIFIED + Verified=Tested`

Information regarding our GitLab pipeline (Schutzbot):

CI will not be ran automatically if WIP label is applied or the PR is in DRAFT state, instead only
a link will be provided to the pipeline which can then be triggered manually if desired. To run the
CI automatically either switch the PR to ready or apply WIP+test label.

Outside contributors need manual approval from one of the osbuild-composer maintainers.

Schutzbot will only be triggered if all Tests jobs in GitHub workflow succeed.
-->


ochosi: Hi and thanks for your contribution! :)
I know this is still a draft PR but what would really help with reviewing this PR is if you could add some explanation about what it does and why. The same goes for the actual commits (https://www.osbuild.org/guides/developer-guide/workflow.html).
And finally, starting today we will use the PR titles directly for our release notes so ideally the title conveys a clear summary of the changes.
ygalblum: @ochosi Sorry for that, all the details are in the first commit. I'll paste them here as well and fix the PR's title
ygalblum: # New Feature
Before this change the worker supported uploading to AWS S3 but only via the REST API. This change enhances this capability in two ways:
1. Support uploading the AWS S3 also via the WELDR API
2. Support non-AWS S3 services (e.g. MinIO)

# Code Changes
## jobimpl-osbuild
Add GenericS3Creds to struct
Add method to create AWS with Endpoint for Generic S3 (with its own credentials file)
Move uploading to S3 and result handling to a separate method
adjust the AWS S3 case to the new method
Implement a new case for uploading to a generic S3 service

## awscloud
Add wrapper methods for endpoint support
Set the endpoint to the AWS session
Set s3ForcePathStyle to true if endpoint was set

## Target
Define a new target type for the GenericS3Target and Options
Handle unmarshaling of the target options and result for the Generic S3

## Weldr
Add support for only uploading to AWS S3
Define new structures for AWS S3 and Generic S3 (based on AWS S3)
Handle unmarshaling of the providers settings' upload settings

## main
Add a section in the main config for the Generic S3 service for credentials
If provided pass the credentials file name to the osbuild job implementation

## Upload Utility
Add upload-generic-s3 utility

## Makefile
Do not fail if the bin directory already exists

ygalblum: @ochosi Please note that I've set it as a Draft PR in order to be able to run the CI before actually setting it as Ready. But, from any other aspect, it is ready for review.
thozza: I would also like to mention, that this PR will have to reflect #2474 if it is merged after it. Or the other way around... 🙂 
ygalblum: >     * You forgot to extend `targetsToUploadResponses()` in `internal/weldr/upload.go` for the newly added targets (AWS S3 and generic S3).
Done 


ygalblum: > Since you technically enabled also AWS S3 for Weldr API, it would be great if you could add an integration test which would test it. Basically a very similar one to the Generic S3 test case, which you added in this PR, but for AWS S3...

I've implemented the test for AWS. The first run failed and I think I fixed it now. Once the tests pass, I'll move the PR to Ready
thozza: The Generic S3 test failed - https://gitlab.com/osbuild/ci/osbuild-composer/-/pipelines/500259589
ygalblum: The new integration tests passed successfully. I've removed the commit that disables all other tests and moved the PR to Ready
thozza: @ygalblum Thanks a lot!

Unfortunately, with the addition of the aws and generic S2 integration test, we are running into a GitLab CI limitation `jobs:integration:parallel:matrix config generates too many jobs (maximum is 50)`. We'll have to split the integration tests into more groups. We'll discuss this within our team later today...
ygalblum: > although you'll still have to adjust and rebase after https://github.com/osbuild/osbuild-composer/pull/2488 gets merged
@thozza Done
thozza: /packit rebuild-failed
thozza: Rebased using GitHub button, so that the CI infra fixed are picked up...
ygalblum: It seems like I'm getting some intermittent failures along with a consistent failure in RHEL and CentosStream 9. For some reason, the test fails to connect to the VM and the logs cannot explain why. I'll try to run it locally

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/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


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/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}"