Repository: osbuild/osbuild-composer
Pull request #1439 (2021-06-01 15:37:25)

Hosted edge commit
This pull request includes:

- [ ] adequate testing for the new functionality or fixed issue
- [x] adequate documentation informing people about the change such as
  - [x] create a file in [news/unreleased](https://github.com/osbuild/osbuild-composer/tree/main/docs/news/unreleased) directory if this change should be mentioned in the release news

<!--
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.

For user-visible changes, "adequate documentation" is an entry describing the
change for users in docs/news. Please refer to docs/news/README.md for details.

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`
-->

This PR adds the ability to build edge commits through the Cloud API and upload them to an S3 bucket.  On successful upload, the service returns a [Presigned URL](https://docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html) that can be used to download the object.

Image builder counterpart: https://github.com/osbuild/image-builder/pull/193

One thing to note is the lifetime of the Presigned URL.  It's currently set to 24 hours, but I don't know if that's something we want to expose or adjust.


~~## Draft~~

~~This will remain a draft until #1351 is merged so I can rebase it.  Wanted to open the PR for visibility and to discuss any issues with the approach.~~

~~I'd also like to get the same treatment for the Edge installer image type (COMPOSER-926) calling it ready, but we can also discuss whether that could be moved to a separate PR.~~

## EDIT

No longer based on 8.5 work in #1351.

teg: Could we land this irrespective of the 8.5 work? As this is only exposed on the cloud API, it should not depend on anything to do with 8.5, I think?
achilleas-k: > Could we land this irrespective of the 8.5 work? As this is only exposed on the cloud API, it should not depend on anything to do with 8.5, I think?

You're absolutely right.  I'll "un-rebase" before marking as ready.
achilleas-k: Cleaned up and ready.
achilleas-k: Added a slightly unrelated change: Requests to the Koji API now read the exports list from the image type.  Since this PR adds the same for the cloud API, I think it's good to finally have all build jobs support this and use the export path when handling the final image.
teg: Very nice! Should we add some upload test too?
achilleas-k: > Very nice! Should we add some upload test too?

Added a test to `test/cases/api.sh` that builds an edge commit, downloads it using the Presigned URL, and checks if it contains the ostree ref that was used in the request.
achilleas-k: Doing some in-CI print debugging because I cannot for the life of me figure out why the commit doesn't contain the ref at the path I'm checking.
achilleas-k: > Doing some in-CI print debugging because I cannot for the life of me figure out why the commit doesn't contain the ref at the path I'm checking.

Found the issue in the request payload.  Should be fixed now :crossed_fingers: 
achilleas-k: After a quick discussion with @teg and @thozza, we agreed that it's better to move the ostree options into the `ImageRequest` part of the request data.

Patch:
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index aee608c30..c84e46989 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -132,9 +132,6 @@ New OSTree:
     - libvirt.sh
     - aws.sh
     - azure.sh
-    - api.sh azure
-    - api.sh aws
-    - api.sh gcp
     - vmware.sh
 
 Integration:
@@ -165,6 +162,42 @@ Integration:
         INTERNAL_NETWORK: ["true"]
         DISTRO_CODE: ["rhel_90"]
 
+
+.API_TESTS: &API_TESTS
+  TARGET:
+    - azure
+    - aws
+    - gcp
+    - aws.s3
+
+API:
+  stage: test
+  extends: .terraform
+  variables:
+    EXTRA_REPO_PATH_SEGMENT: "gitlab/"
+  script:
+    - schutzbot/deploy.sh
+    - /usr/libexec/tests/osbuild-composer/api.sh ${TARGET}
+  parallel:
+    matrix:
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/fedora-32-x86_64
+          - aws/fedora-33-x86_64
+          # See COMPOSER-919
+          # - aws/fedora-34-x86_64
+          - openstack/centos-stream-8-x86_64
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/rhel-8-x86_64
+          - aws/rhel-8.4-x86_64
+        INTERNAL_NETWORK: ["true"]
+      - <<: *API_TESTS
+        RUNNER:
+          - aws/rhel-8-x86_64
+        INTERNAL_NETWORK: ["true"]
+        DISTRO_CODE: ["rhel_90"]
+
 finish:
   stage: finish
   tags:
diff --git a/cmd/osbuild-worker/jobimpl-osbuild-koji.go b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
index e23ee46fb..c3d50249e 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild-koji.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild-koji.go
@@ -85,15 +85,27 @@ func (impl *OSBuildKojiJobImpl) Run(job worker.Job) error {
 		return err
 	}
 
-	exports := []string{"assembler"} // NOTE: Koji API doesn't support new image types yet
 	if initArgs.KojiError == "" {
+		exports := args.Exports
+		if len(exports) == 0 {
+			// job did not define exports, likely coming from an older version of composer
+			// fall back to default "assembler"
+			exports = []string{"assembler"}
+		} else if len(exports) > 1 {
+			// this worker only supports returning one (1) export
+			return fmt.Errorf("at most one build artifact can be exported")
+		}
 		result.OSBuildOutput, err = RunOSBuild(args.Manifest, impl.Store, outputDirectory, exports, os.Stderr)
 		if err != nil {
 			return err
 		}
 
+		// NOTE: Currently OSBuild supports multiple exports, but this isn't used
+		// by any of the image types and it can't be specified during the request.
+		// Use the first (and presumably only) export for the imagePath.
+		exportPath := exports[0]
 		if result.OSBuildOutput.Success {
-			f, err := os.Open(path.Join(outputDirectory, args.ImageName))
+			f, err := os.Open(path.Join(outputDirectory, exportPath, args.ImageName))
 			if err != nil {
 				return err
 			}
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index 17bb7522e..a9b1e4c00 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -219,7 +219,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				key = uuid.New().String()
 			}
 
-			_, err = a.Upload(path.Join(outputDirectory, options.Filename), options.Bucket, key)
+			_, err = a.Upload(path.Join(outputDirectory, exportPath, options.Filename), options.Bucket, key)
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
 				return nil
@@ -241,6 +241,34 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 				Region: options.Region,
 			}))
 
+			osbuildJobResult.Success = true
+			osbuildJobResult.UploadStatus = "success"
+		case *target.AWSS3TargetOptions:
+			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+
+			key := options.Key
+			if key == "" {
+				key = uuid.New().String()
+			}
+			key += "-" + options.Filename
+
+			_, err = a.Upload(path.Join(outputDirectory, exportPath, options.Filename), options.Bucket, key)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+			url, err := a.S3ObjectPresignedURL(options.Bucket, key)
+			if err != nil {
+				appendTargetError(osbuildJobResult, err)
+				return nil
+			}
+
+			osbuildJobResult.TargetResults = append(osbuildJobResult.TargetResults, target.NewAWSS3TargetResult(&target.AWSS3TargetResultOptions{URL: url}))
+
 			osbuildJobResult.Success = true
 			osbuildJobResult.UploadStatus = "success"
 		case *target.AzureTargetOptions:
@@ -259,7 +287,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			const azureMaxUploadGoroutines = 4
 			err = azureStorageClient.UploadPageBlob(
 				metadata,
-				path.Join(outputDirectory, options.Filename),
+				path.Join(outputDirectory, exportPath, options.Filename),
 				azureMaxUploadGoroutines,
 			)
 
@@ -280,7 +308,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			}
 
 			log.Printf("[GCP] 🚀 Uploading image to: %s/%s", options.Bucket, options.Object)
-			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, options.Filename),
+			_, err = g.StorageObjectUpload(ctx, path.Join(outputDirectory, exportPath, options.Filename),
 				options.Bucket, options.Object, map[string]string{gcp.MetadataKeyImageName: args.Targets[0].ImageName})
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
@@ -421,7 +449,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 					ContainerName:  storageContainer,
 					BlobName:       blobName,
 				},
-				path.Join(outputDirectory, options.Filename),
+				path.Join(outputDirectory, exportPath, options.Filename),
 				azure.DefaultUploadThreads,
 			)
 			if err != nil {
diff --git a/distribution/Dockerfile-worker b/distribution/Dockerfile-worker
index ee02a91de..4c4376828 100644
--- a/distribution/Dockerfile-worker
+++ b/distribution/Dockerfile-worker
@@ -8,7 +8,7 @@ RUN dnf install -y qemu-img osbuild osbuild-ostree
 RUN mkdir -p "/usr/libexec/osbuild-composer"
 RUN mkdir -p "/etc/osbuild-composer/"
 RUN mkdir -p "/run/osbuild-composer/"
-RUN mkdir -p "/var/cache/osbuild-composer/"
+RUN mkdir -p "/var/cache/osbuild-worker/"
 RUN mkdir -p "/var/lib/osbuild-composer/"
 COPY --from=builder /opt/app-root/src/go/bin/osbuild-worker /usr/libexec/osbuild-composer/
 
diff --git a/docs/news/unreleased/hosted-edge-commit.md b/docs/news/unreleased/hosted-edge-commit.md
new file mode 100644
index 000000000..932f69056
--- /dev/null
+++ b/docs/news/unreleased/hosted-edge-commit.md
@@ -0,0 +1,5 @@
+# Build Edge commits in Image Builder and upload to S3
+
+Edge commit image types can now be built through the Cloud API (Image Builder).  Edge commits are uploaded to an S3 bucket and are downloadable through a presigned URL that is available for up to 7 days.
+
+PR: https://github.com/osbuild/osbuild-composer/pull/1439
diff --git a/internal/cloudapi/openapi.gen.go b/internal/cloudapi/openapi.gen.go
index 484acb143..f1ebf7db8 100644
--- a/internal/cloudapi/openapi.gen.go
+++ b/internal/cloudapi/openapi.gen.go
@@ -20,6 +20,17 @@ import (
 	"strings"
 )
 
+// AWSS3UploadRequestOptions defines model for AWSS3UploadRequestOptions.
+type AWSS3UploadRequestOptions struct {
+	Region string                    `json:"region"`
+	S3     AWSUploadRequestOptionsS3 `json:"s3"`
+}
+
+// AWSS3UploadStatus defines model for AWSS3UploadStatus.
+type AWSS3UploadStatus struct {
+	Url string `json:"url"`
+}
+
 // AWSUploadRequestOptions defines model for AWSUploadRequestOptions.
 type AWSUploadRequestOptions struct {
 	Ec2    AWSUploadRequestOptionsEc2 `json:"ec2"`
@@ -144,6 +155,7 @@ type GCPUploadStatus struct {
 type ImageRequest struct {
 	Architecture  string        `json:"architecture"`
 	ImageType     string        `json:"image_type"`
+	Ostree        *OSTree       `json:"ostree,omitempty"`
 	Repositories  []Repository  `json:"repositories"`
 	UploadRequest UploadRequest `json:"upload_request"`
 }
@@ -167,6 +179,12 @@ const (
 	ImageStatusValue_uploading   ImageStatusValue = "uploading"
 )
 
+// OSTree defines model for OSTree.
+type OSTree struct {
+	Ref *string `json:"ref,omitempty"`
+	Url *string `json:"url,omitempty"`
+}
+
 // Repository defines model for Repository.
 type Repository struct {
 	Baseurl    *string `json:"baseurl,omitempty"`
@@ -202,9 +220,10 @@ type UploadTypes string
 
 // List of UploadTypes
 const (
-	UploadTypes_aws   UploadTypes = "aws"
-	UploadTypes_azure UploadTypes = "azure"
-	UploadTypes_gcp   UploadTypes = "gcp"
+	UploadTypes_aws    UploadTypes = "aws"
+	UploadTypes_aws_s3 UploadTypes = "aws.s3"
+	UploadTypes_azure  UploadTypes = "azure"
+	UploadTypes_gcp    UploadTypes = "gcp"
 )
 
 // Version defines model for Version.
@@ -868,52 +887,54 @@ func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler {
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"H4sIAAAAAAAC/8RZe2/bNtf/KoT2AtkAS3Js52Zg2LI0K7JLU9Rp3w11ENDSscVVIjWSipMV/u4PDknJ",
-	"usVx+nR4/rIlkufyO4fnps9eJLJccOBaedPPnooSyKj5e/7/s/d5Kmj8Dv4uQOnrXDPBzVIuRQ5SMzBP",
-	"EI3w5/8kLL2p9024pRg6cuETtC6jkbcZeBJWTHBD6oFmeQre1IPCX4PS/qE38PRjjq+Uloyv8IAafyHD",
-	"2djbGIZ/F0xC7E0/lswN0YHR5bbiKBZ/QaSR4w4FOnjQKAKl7j7B4x2Lm1qd/3p1fnU9+/n61Zs3J5d/",
-	"nP/+9rfLXgUhkqDvtpSaZNa/0FT+8V7zny9/vwp/Pfn91eWb1+Hi7cO7Jbv409H99fJPb+Athcyo9qZe",
-	"TpVaCxn3skuohLs10wmyFIVzhorhR+9wNJ4cHZ+cng0PDUBMQ2b2dGi5F1RK+mhoc5qrROg7TjNoqpE9",
-	"+uVqV6qWmZqg9iH0ArPNxv+K1RZF9Al0R0f3+n9t5hcDWim0E9mZprroiQo0Y01taMb8YXQ6Hp6cjU9O",
-	"jo7OjuLJog+VF4aDtl4Z8yoavZL/U0jYL7KxjK6gctwYVCSZ2etNvTc0AyKWRCdACkMNYmIOBORKk6xQ",
-	"miyAFJz9XQBh3GxcsXvgRIIShYyArKQo8mDOr5YEmRCmiMiY1hCTpRSZOSKtjANCiaQ8FhkRHMiCKoiJ",
-	"4ISS9++vXhGm5nwFHCTVEAdzjGcNHzSC9YGdiohqB3dTwd/cClknIMHIYqgQlYgijY1ypd6UxwQhVxok",
-	"xAG5SZgiKeOfCDzkKWV8zhOxJlqQlClNaJqSkrGaznmida6mYRiLSAUZi6RQYqmDSGQhcL9QYZSykKLd",
-	"QheffrhnsP7evPKjlPkp1aD0N/SfMoDdIaO7islBCxJ0JijQ2P0eaA10Zwy02/ZNY+4BVts6N6KIKH/n",
-	"yLw2HPtiRbGoRHARqinU1SsUqb7tC4SZwFF8uhhFPl2MJv5kcjj2z4bRkX98OBoPj+F0eAajPuk0cMr1",
-	"DrlQCLtpH6m6DqRIItZzrgVZMh4TpssrZa4zeSukpuk+rlS6kWb34MdMQqSFfAyXBY9pBlzTVHVW/USs",
-	"fS18ZO1bLVq4HUUnsDxaHPuH0XjpT2I69OnxaOQPF8Pj4Wh8Fp/EJ8+Gri2IXXN3nLJ2dZ+Jck9F6GZ0",
-	"2ydctOStEegT4QLLMgUuyHb5R4XSImP/0Cr67qroLpq7NwMvZijXotCdbCETSP3TPj+1IruYalEoK5ld",
-	"zK/wWKlIp8hpwdKQq8NyJ1KqSHuAatcjh6MxYDXmw+nZwj8cxWOfTo6O/cno+PjoaDIZDofDek1QFOz5",
-	"eoDF3u1WlN0+o6rVZ0FzhPpdx9ExfDvO0GSc0+gTXUG7Ls2F0isJ6oU1ae1yPafFrL53s+mx3uuLt/uV",
-	"E9v6sD+dUE7ggSnN+IrMbs7fvDp/94rMtJAYJaOUKkV+MiSCdnp3DztKzV2lzE0Ctv7QghQKyFJIF55z",
-	"IbVL76ZHiAn6R6GBXPIV4y6CB3N+U0VzQ6hV/WBn4cL164u3JJcCsRuQdcKiBKueQkE85yXf65mjZfOB",
-	"YW9lCQiWSkITlUPElgxlc2XRnB9E1nelT3Pmz4vhcByh65t/cEAsGCU7QlUtB6HULymbtjVqF0pU0a7X",
-	"Ul2l05qlKUJTgatFHV+s+xye9zQttlBSfGaxoV5G/oDMAEiZ8qJUFHGwEmKVgkl4yrqOyYVhVQq5erMO",
-	"4sCImBWpZr6TvNxOolQoUBrFxE02B835t67qKd3TOmZ17DuEOUqEAk5ooUVGNYtomj62QYbiBQ1pq0DF",
-	"UlIsS1yM3qTcjvIaKk1P7nNf457BnF/SKCmdxKAeCa4pwxq7REqWpYxjQ1DygHwwEth4qwiVMJ1zQnxy",
-	"UCiQ08+QUZayeHMwJeecmCdC41iCQhekmkjIJSgMO1teEZIgLbUC8rOQxKE3IAc0ZRH86J7R5geB46xA",
-	"3rMIzu25F8pgWTsST/HOHn2hE3Pb8h9pnqtc6GDlDpVn6iKZuuWlaDj9y04J5WpBEGeMq14MYpFRxqef",
-	"7S8yNNeTzAqmgdi35NtcsozKx++6zNPUMjQtngKprPWpdmfbiGyv3gERkhy0ZOq/dbtdkyl7xgYHdFRC",
-	"+eOcl/g2b9NHzzhcxytMe9/wh32N5w08a7YuzN7AcwDXX74gD7dKgh3DhirDfr1SduC5LNSZ9lAVAY8p",
-	"1/5CUhb74+H46HD8bP1UIzd4rjJulJPdyYmMEqYh0oVsqfNwenx3PHk6vdvXraFLf/rKhWJayBK/fYrg",
-	"d+Whx76ayubqss59jlajYOrOcOoINJRrid5he1ui+5SnvLh0/YBZuKbgfgQa7tpWr1b2dhih9XiRmW2F",
-	"mcVhJU9ZaqHIgcdow4G3KFjq/lrJ7P9yCoNPtz2WrxmxW59SBYVMmx5UVRcxDyTECbXNNOZG4DrEZifE",
-	"fus0PA2tf4ZIR6hQqLDRhci0zxUz0BQb/X6uGZNSSBUsIRaSujsWCLkKy3M/oEN8b9f98QiLvdExOtD3",
-	"1W15VgTDJGVKv1iI6mRTjPGXiCETldXC5kKIFCjvfrDAbX1RZdbqatrzbc3uTW3mdwbN2aNvx7++nfvu",
-	"9dEArez3ukvXW/bQnnHFVknrw4OWBQw6gAw8IVeUu2axcWA0nAzHo0l1hnENK5B22C7vQXYlrjeDAYJb",
-	"E/zZqN8QZNAGucG0hlhN2z5DNoNjx5Ji218KDtdLb/rxiz6GeZvB7nNPNbbPnXt6wr65rTLHPvHz5jGH",
-	"bvh0iaCE4WkEn8oBXw5gGdD3BW7P/d1hnQFqm2r2Swmy4PypuP/fgu5kGXTQr9C252rC0jXuX0U5XgzU",
-	"sFewDyBVb8C63y7svoPlxtvNxsSRpej2ijPXy2hBTOK0MwWuNE1TW2qrwBt4WDhzZYCyxaR3ntMoATIK",
-	"hphoMXZUaWG9XgfULJtc4M6q8Leri8s3s0t/FAyDRGepgZ9pE2yuZz8Z9m7MJolp2gnNsUyrNPYOTZDL",
-	"gePC1BsHw+AQTU11YrAJ3ajDoCZUz0zpQgLVQCjhsCZu94DkApM2w0Ycu1vlhk1iSRTcg6QlFgYeN30B",
-	"bItt988kiQGPuEmC8QOQ5ukqRq5OLGsgUPonEZtc48oFk4jyPGV2ShD+payBrQc+OwJuDpQ3TUfAXGG/",
-	"3eQC7YDURsPDr8/dDGkN8xbkdgNJqCJKU2zqjK+qIsMGc2uU0ni4WFoy/MziDYqw6psQvgZtpy/mFppZ",
-	"IXG3HTtNpJECNpGOmvuAwniUFjEosk4Auz3ci+0k08REEoixC0Vb01QJgiUVwfuDmZoJTuhCFLr8ylWk",
-	"+kmDz8rokFNJM9AglQmqfV+CnIilLlqQlRlZMm4KDp14g/Lyue8edQsPatb66hPx2477DL+2+1QtQcd9",
-	"mrhgAJh02Gt40KH5HtZk3FakQ/yK2ylZyYTFlsHkazF4zz9xseYNBg3fv2m5b+MSuFAXlJC6S9D0tdeg",
-	"r+2+X5Sptvps1ZRKgi4kV0TjbYhFVGSoZ1OwlbtbTgaCMlRDuLKw03SFHm26FUw0Ay+s5afeO1vSLcdo",
-	"5f5BV60P1dK/5n4lix7T0Y6I/QB1d202/wkAAP//LjDNaEAmAAA=",
+	"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",
+	"FIgoAAA=",
 }
 
 // GetSwagger returns the Swagger specification corresponding to the generated code
diff --git a/internal/cloudapi/openapi.yml b/internal/cloudapi/openapi.yml
index ba10faf25..9c76b7531 100644
--- a/internal/cloudapi/openapi.yml
+++ b/internal/cloudapi/openapi.yml
@@ -121,6 +121,7 @@ components:
         options:
           oneOf:
             - $ref: '#/components/schemas/AWSUploadStatus'
+            - $ref: '#/components/schemas/AWSS3UploadStatus'
             - $ref: '#/components/schemas/GCPUploadStatus'
             - $ref: '#/components/schemas/AzureUploadStatus'
     AWSUploadStatus:
@@ -135,6 +136,13 @@ components:
         region:
           type: string
           example: 'eu-west-1'
+    AWSS3UploadStatus:
+      type: object
+      required:
+        - url
+      properties:
+        url:
+          type: string
     GCPUploadStatus:
       type: object
       required:
@@ -187,6 +195,8 @@ components:
           type: array
           items:
             $ref: '#/components/schemas/Repository'
+        ostree:
+          $ref: '#/components/schemas/OSTree'
         upload_request:
           $ref: '#/components/schemas/UploadRequest'
     Repository:
@@ -219,11 +229,12 @@ components:
         options:
           oneOf:
             -  $ref: '#/components/schemas/AWSUploadRequestOptions'
+            -  $ref: '#/components/schemas/AWSS3UploadRequestOptions'
             -  $ref: '#/components/schemas/GCPUploadRequestOptions'
             -  $ref: '#/components/schemas/AzureUploadRequestOptions'
     UploadTypes:
       type: string
-      enum: ['aws', 'gcp', 'azure']
+      enum: ['aws', 'aws.s3', 'gcp', 'azure']
     AWSUploadRequestOptions:
       type: object
       required:
@@ -238,6 +249,17 @@ components:
           $ref: '#/components/schemas/AWSUploadRequestOptionsS3'
         ec2:
           $ref: '#/components/schemas/AWSUploadRequestOptionsEc2'
+    AWSS3UploadRequestOptions:
+      type: object
+      required:
+        - region
+        - s3
+      properties:
+        region:
+          type: string
+          example: 'eu-west-1'
+        s3:
+          $ref: '#/components/schemas/AWSUploadRequestOptionsS3'
     AWSUploadRequestOptionsS3:
       type: object
       required:
@@ -378,6 +400,14 @@ components:
           example: ['postgres']
           items:
             type: string
+    OSTree:
+      type: object
+      properties:
+        url:
+          type: string
+        ref:
+          type: string
+          example: ['rhel/8/x86_64/edge']
     Subscription:
       type: object
       required:
diff --git a/internal/cloudapi/server.go b/internal/cloudapi/server.go
index fb0da8e65..2ae38cbaf 100644
--- a/internal/cloudapi/server.go
+++ b/internal/cloudapi/server.go
@@ -19,6 +19,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/blueprint"
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/distroregistry"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/target"
 	"github.com/osbuild/osbuild-composer/internal/worker"
@@ -136,6 +137,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 	type imageRequest struct {
 		manifest distro.Manifest
 		arch     string
+		exports  []string
 	}
 	imageRequests := make([]imageRequest, len(request.ImageRequests))
 	var targets []*target.Target
@@ -196,6 +198,28 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 
+		// set default ostree ref, if one not provided
+		ostreeOptions := ir.Ostree
+		if ostreeOptions == nil || ostreeOptions.Ref == nil {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: imageType.OSTreeRef()}
+		} else if !ostree.VerifyRef(*ostreeOptions.Ref) {
+			http.Error(w, fmt.Sprintf("Invalid OSTree ref: %s", *ostreeOptions.Ref), http.StatusBadRequest)
+			return
+		} else {
+			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
+		}
+
+		var parent string
+		if ostreeOptions != nil && ostreeOptions.Url != nil {
+			imageOptions.OSTree.URL = *ostreeOptions.Url
+			parent, err = ostree.ResolveRef(imageOptions.OSTree.URL, imageOptions.OSTree.Ref)
+			if err != nil {
+				http.Error(w, fmt.Sprintf("Error resolving OSTree repo %s: %s", imageOptions.OSTree.URL, err), http.StatusBadRequest)
+				return
+			}
+			imageOptions.OSTree.Parent = parent
+		}
+
 		manifest, err := imageType.Manifest(nil, imageOptions, repositories, pkgSpecSets, manifestSeed)
 		if err != nil {
 			http.Error(w, fmt.Sprintf("Failed to get manifest for for %s/%s/%s: %s", ir.ImageType, ir.Architecture, request.Distribution, err), http.StatusBadRequest)
@@ -204,6 +228,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 
 		imageRequests[i].manifest = manifest
 		imageRequests[i].arch = arch.Name()
+		imageRequests[i].exports = imageType.Exports()
 
 		uploadRequest := ir.UploadRequest
 		/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
@@ -240,6 +265,31 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 				t.ImageName = key
 			}
 
+			targets = append(targets, t)
+		} else if uploadRequest.Type == UploadTypes_aws_s3 {
+			var awsS3UploadOptions AWSS3UploadRequestOptions
+			jsonUploadOptions, err := json.Marshal(uploadRequest.Options)
+			if err != nil {
+				http.Error(w, "Unable to marshal aws upload request", http.StatusInternalServerError)
+				return
+			}
+			err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
+			if err != nil {
+				http.Error(w, "Unable to unmarshal aws upload request", http.StatusInternalServerError)
+				return
+			}
+
+			key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+			t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
+				Filename:        imageType.Filename(),
+				Region:          awsS3UploadOptions.Region,
+				AccessKeyID:     awsS3UploadOptions.S3.AccessKeyId,
+				SecretAccessKey: awsS3UploadOptions.S3.SecretAccessKey,
+				Bucket:          awsS3UploadOptions.S3.Bucket,
+				Key:             key,
+			})
+			t.ImageName = key
+
 			targets = append(targets, t)
 		} else if uploadRequest.Type == UploadTypes_gcp {
 			var gcpUploadOptions GCPUploadRequestOptions
@@ -325,6 +375,7 @@ func (server *Server) Compose(w http.ResponseWriter, r *http.Request) {
 	id, err := server.workers.EnqueueOSBuild(ir.arch, &worker.OSBuildJob{
 		Manifest: ir.manifest,
 		Targets:  targets,
+		Exports:  ir.exports,
 	})
 	if err != nil {
 		http.Error(w, "Failed to enqueue manifest", http.StatusInternalServerError)
@@ -376,6 +427,12 @@ func (server *Server) ComposeStatus(w http.ResponseWriter, r *http.Request, id s
 				Ami:    awsOptions.Ami,
 				Region: awsOptions.Region,
 			}
+		case "org.osbuild.aws.s3":
+			uploadType = UploadTypes_aws_s3
+			awsOptions := tr.Options.(*target.AWSS3TargetResultOptions)
+			uploadOptions = AWSS3UploadStatus{
+				Url: awsOptions.URL,
+			}
 		case "org.osbuild.gcp":
 			uploadType = UploadTypes_gcp
 			gcpOptions := tr.Options.(*target.GCPTargetResultOptions)
diff --git a/internal/kojiapi/server.go b/internal/kojiapi/server.go
index 78d67a7e0..416a5e32e 100644
--- a/internal/kojiapi/server.go
+++ b/internal/kojiapi/server.go
@@ -85,6 +85,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		manifest distro.Manifest
 		arch     string
 		filename string
+		exports  []string
 	}
 
 	imageRequests := make([]imageRequest, len(request.ImageRequests))
@@ -138,6 +139,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		imageRequests[i].manifest = manifest
 		imageRequests[i].arch = arch.Name()
 		imageRequests[i].filename = imageType.Filename()
+		imageRequests[i].exports = imageType.Exports()
 
 		kojiFilenames[i] = fmt.Sprintf(
 			"%s-%s-%s.%s%s",
@@ -165,6 +167,7 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		id, err := h.server.workers.EnqueueOSBuildKoji(ir.arch, &worker.OSBuildKojiJob{
 			Manifest:      ir.manifest,
 			ImageName:     ir.filename,
+			Exports:       ir.exports,
 			KojiServer:    request.Koji.Server,
 			KojiDirectory: kojiDirectory,
 			KojiFilename:  kojiFilenames[i],
diff --git a/internal/ostree/ostree.go b/internal/ostree/ostree.go
new file mode 100644
index 000000000..20822ada0
--- /dev/null
+++ b/internal/ostree/ostree.go
@@ -0,0 +1,53 @@
+package ostree
+
+import (
+	"encoding/hex"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"path"
+	"regexp"
+	"strings"
+)
+
+var ostreeRefRE = regexp.MustCompile(`^(?:[\w\d][-._\w\d]*\/)*[\w\d][-._\w\d]*$`)
+
+type OSTreeRequest struct {
+	URL    string `json:"url"`
+	Ref    string `json:"ref"`
+	Parent string `json:"parent"`
+}
+
+func VerifyRef(ref string) bool {
+	if len(ref) > 0 && ostreeRefRE.MatchString(ref) {
+		return true
+	}
+	return false
+}
+
+func ResolveRef(location, ref string) (string, error) {
+	u, err := url.Parse(location)
+	if err != nil {
+		return "", err
+	}
+	u.Path = path.Join(u.Path, "refs/heads/", ref)
+	resp, err := http.Get(u.String())
+	if err != nil {
+		return "", err
+	}
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("ostree repository %q returned status: %s", u.String(), resp.Status)
+	}
+	body, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return "", err
+	}
+	parent := strings.TrimSpace(string(body))
+	// Check that this is at least a hex string.
+	_, err = hex.DecodeString(parent)
+	if err != nil {
+		return "", fmt.Errorf("ostree repository %q returned invalid reference", u.String())
+	}
+	return parent, nil
+}
diff --git a/internal/target/aws_target.go b/internal/target/aws_target.go
index 0410776fb..095a31f78 100644
--- a/internal/target/aws_target.go
+++ b/internal/target/aws_target.go
@@ -26,3 +26,28 @@ func (AWSTargetResultOptions) isTargetResultOptions() {}
 func NewAWSTargetResult(options *AWSTargetResultOptions) *TargetResult {
 	return newTargetResult("org.osbuild.aws", options)
 }
+
+type AWSS3TargetOptions struct {
+	Filename        string `json:"filename"`
+	Region          string `json:"region"`
+	AccessKeyID     string `json:"accessKeyID"`
+	SecretAccessKey string `json:"secretAccessKey"`
+	Bucket          string `json:"bucket"`
+	Key             string `json:"key"`
+}
+
+func (AWSS3TargetOptions) isTargetOptions() {}
+
+func NewAWSS3Target(options *AWSS3TargetOptions) *Target {
+	return newTarget("org.osbuild.aws.s3", options)
+}
+
+type AWSS3TargetResultOptions struct {
+	URL string `json:"url"`
+}
+
+func (AWSS3TargetResultOptions) isTargetResultOptions() {}
+
+func NewAWSS3TargetResult(options *AWSS3TargetResultOptions) *TargetResult {
+	return newTargetResult("org.osbuild.aws.s3", options)
+}
diff --git a/internal/target/target.go b/internal/target/target.go
index 4bdd60771..ed7f7f7c5 100644
--- a/internal/target/target.go
+++ b/internal/target/target.go
@@ -69,6 +69,8 @@ func UnmarshalTargetOptions(targetName string, rawOptions json.RawMessage) (Targ
 		options = new(AzureTargetOptions)
 	case "org.osbuild.aws":
 		options = new(AWSTargetOptions)
+	case "org.osbuild.aws.s3":
+		options = new(AWSS3TargetOptions)
 	case "org.osbuild.gcp":
 		options = new(GCPTargetOptions)
 	case "org.osbuild.azure.image":
diff --git a/internal/target/targetresult.go b/internal/target/targetresult.go
index ed1c820e2..8f417418e 100644
--- a/internal/target/targetresult.go
+++ b/internal/target/targetresult.go
@@ -47,6 +47,8 @@ func UnmarshalTargetResultOptions(trName string, rawOptions json.RawMessage) (Ta
 	switch trName {
 	case "org.osbuild.aws":
 		options = new(AWSTargetResultOptions)
+	case "org.osbuild.aws.s3":
+		options = new(AWSS3TargetResultOptions)
 	case "org.osbuild.gcp":
 		options = new(GCPTargetResultOptions)
 	case "org.osbuild.azure.image":
diff --git a/internal/upload/awsupload/awsupload.go b/internal/upload/awsupload/awsupload.go
index cd8c60df9..ba2e7efa8 100644
--- a/internal/upload/awsupload/awsupload.go
+++ b/internal/upload/awsupload/awsupload.go
@@ -284,3 +284,17 @@ func (a *AWS) Register(name, bucket, key string, shareWith []string, rpmArch str
 
 	return registerOutput.ImageId, nil
 }
+
+func (a *AWS) S3ObjectPresignedURL(bucket, objectKey string) (string, error) {
+	log.Printf("[AWS] 📋 Generating Presigned URL for S3 object %s/%s", bucket, objectKey)
+	req, _ := a.s3.GetObjectRequest(&s3.GetObjectInput{
+		Bucket: aws.String(bucket),
+		Key:    aws.String(objectKey),
+	})
+	url, err := req.Presign(7 * 24 * time.Hour) // maximum allowed
+	if err != nil {
+		return "", err
+	}
+	log.Print("[AWS] 🎉 S3 Presigned URL ready")
+	return url, nil
+}
diff --git a/internal/weldr/api.go b/internal/weldr/api.go
index 169c9854a..6fd746c34 100644
--- a/internal/weldr/api.go
+++ b/internal/weldr/api.go
@@ -4,7 +4,6 @@ import (
 	"archive/tar"
 	"bytes"
 	"crypto/rand"
-	"encoding/hex"
 	"encoding/json"
 	errors_package "errors"
 	"fmt"
@@ -33,6 +32,7 @@ import (
 	"github.com/osbuild/osbuild-composer/internal/distro"
 	"github.com/osbuild/osbuild-composer/internal/jobqueue"
 	osbuild "github.com/osbuild/osbuild-composer/internal/osbuild1"
+	"github.com/osbuild/osbuild-composer/internal/ostree"
 	"github.com/osbuild/osbuild-composer/internal/reporegistry"
 	"github.com/osbuild/osbuild-composer/internal/rpmmd"
 	"github.com/osbuild/osbuild-composer/internal/store"
@@ -93,7 +93,6 @@ func (api *API) systemRepoNames() (names []string) {
 }
 
 var ValidBlueprintName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
-var ValidOSTreeRef = regexp.MustCompile(`^(?:[\w\d][-._\w\d]*\/)*[\w\d][-._\w\d]*$`)
 
 func New(rpmmd rpmmd.RPMMD, arch distro.Arch, distro distro.Distro, repoRegistry *reporegistry.RepoRegistry, logger *log.Logger, store *store.Store, workers *worker.Server, compatOutputDir string) *API {
 	api := &API{
@@ -377,18 +376,6 @@ func verifyStringsWithRegex(writer http.ResponseWriter, strings []string, re *re
 	return true
 }
 
-func verifyOSTreeRef(writer http.ResponseWriter, ref string, re *regexp.Regexp) bool {
-	if len(ref) > 0 && re.MatchString(ref) {
-		return true
-	}
-	errors := responseError{
-		ID:  "InvalidChars",
-		Msg: "Invalid ostree ref",
-	}
-	statusResponseError(writer, http.StatusBadRequest, errors)
-	return false
-}
-
 func statusResponseError(writer http.ResponseWriter, code int, errors ...responseError) {
 	type reply struct {
 		Status bool            `json:"status"`
@@ -1847,32 +1834,6 @@ func (api *API) blueprintsTagHandler(writer http.ResponseWriter, request *http.R
 	statusResponseOK(writer)
 }
 
-func ostreeResolveRef(location, ref string) (string, error) {
-	u, err := url.Parse(location)
-	if err != nil {
-		return "", err
-	}
-	u.Path = path.Join(u.Path, "refs/heads/", ref)
-	resp, err := http.Get(u.String())
-	if err != nil {
-		return "", err
-	}
-	if resp.StatusCode != http.StatusOK {
-		return "", fmt.Errorf("ostree repository %q returned status: %s", u.String(), resp.Status)
-	}
-	body, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		return "", err
-	}
-	parent := strings.TrimSpace(string(body))
-	// Check that this is at least a hex string.
-	_, err = hex.DecodeString(parent)
-	if err != nil {
-		return "", fmt.Errorf("ostree repository %q returned invalid reference", u.String())
-	}
-	return parent, nil
-}
-
 func (api *API) depsolveBlueprintForImageType(bp *blueprint.Blueprint, imageType distro.ImageType) (map[string][]rpmmd.PackageSpec, error) {
 	packageSets := imageType.PackageSets(*bp)
 	packageSpecSets := make(map[string][]rpmmd.PackageSpec)
@@ -1899,20 +1860,14 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 		return
 	}
 
-	type OSTreeRequest struct {
-		URL    string `json:"url"`
-		Ref    string `json:"ref"`
-		Parent string `json:"parent"`
-	}
-
 	// https://weldr.io/lorax/pylorax.api.html#pylorax.api.v0.v0_compose_start
 	type ComposeRequest struct {
-		BlueprintName string         `json:"blueprint_name"`
-		ComposeType   string         `json:"compose_type"`
-		Size          uint64         `json:"size"`
-		OSTree        OSTreeRequest  `json:"ostree"`
-		Branch        string         `json:"branch"`
-		Upload        *uploadRequest `json:"upload"`
+		BlueprintName string               `json:"blueprint_name"`
+		ComposeType   string               `json:"compose_type"`
+		Size          uint64               `json:"size"`
+		OSTree        ostree.OSTreeRequest `json:"ostree"`
+		Branch        string               `json:"branch"`
+		Upload        *uploadRequest       `json:"upload"`
 	}
 	type ComposeReply struct {
 		BuildID uuid.UUID `json:"build_id"`
@@ -1954,7 +1909,12 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 	// set default ostree ref, if one not provided
 	if cr.OSTree.Ref == "" {
 		cr.OSTree.Ref = imageType.OSTreeRef()
-	} else if !verifyOSTreeRef(writer, cr.OSTree.Ref, ValidOSTreeRef) {
+	} else if !ostree.VerifyRef(cr.OSTree.Ref) {
+		errors := responseError{
+			ID:  "InvalidChars",
+			Msg: "Invalid ostree ref",
+		}
+		statusResponseError(writer, http.StatusBadRequest, errors)
 		return
 	}
 
@@ -2011,7 +1971,7 @@ func (api *API) composeHandler(writer http.ResponseWriter, request *http.Request
 			parent = "02604b2da6e954bd34b8b82a835e5a77d2b60ffa"
 		} else {
 			// Resolve the URL and get the parent commit
-			parent, err = ostreeResolveRef(cr.OSTree.URL, cr.OSTree.Ref)
+			parent, err = ostree.ResolveRef(cr.OSTree.URL, cr.OSTree.Ref)
 			if err != nil {
 				errors := responseError{
 					ID:  "OSTreeCommitError",
diff --git a/internal/worker/json.go b/internal/worker/json.go
index 753731852..896703443 100644
--- a/internal/worker/json.go
+++ b/internal/worker/json.go
@@ -45,6 +45,7 @@ type KojiInitJobResult struct {
 type OSBuildKojiJob struct {
 	Manifest      distro.Manifest `json:"manifest"`
 	ImageName     string          `json:"image_name"`
+	Exports       []string        `json:"exports"`
 	KojiServer    string          `json:"koji_server"`
 	KojiDirectory string          `json:"koji_directory"`
 	KojiFilename  string          `json:"koji_filename"`


Test patch:
diff --git a/internal/ostree/ostree_test.go b/internal/ostree/ostree_test.go
new file mode 100644
index 000000000..2ca929c62
--- /dev/null
+++ b/internal/ostree/ostree_test.go
@@ -0,0 +1,84 @@
+package ostree
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestOstreeResolveRef(t *testing.T) {
+	goodRef := "5330bb1b8820944567f519de66ad6354c729b6b490dea1c5a7ba320c9f147c58"
+	badRef := "<html>not a ref</html>"
+
+	handler := http.NewServeMux()
+	handler.HandleFunc("/refs/heads/rhel/8/x86_64/edge", func(w http.ResponseWriter, r *http.Request) {
+		http.NotFound(w, r)
+	})
+	handler.HandleFunc("/refs/heads/test_forbidden", func(w http.ResponseWriter, r *http.Request) {
+		http.Error(w, "", http.StatusForbidden)
+	})
+	handler.HandleFunc("/refs/heads/get_bad_ref", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, badRef)
+	})
+
+	handler.HandleFunc("/refs/heads/test_redir", func(w http.ResponseWriter, r *http.Request) {
+		http.Redirect(w, r, "/refs/heads/valid/ostree/ref", http.StatusFound)
+	})
+	handler.HandleFunc("/refs/heads/valid/ostree/ref", func(w http.ResponseWriter, r *http.Request) {
+		fmt.Fprint(w, goodRef)
+	})
+
+	srv := httptest.NewServer(handler)
+	defer srv.Close()
+
+	type input struct {
+		location string
+		ref      string
+	}
+	validCases := map[input]string{
+		{srv.URL, "test_redir"}:       goodRef,
+		{srv.URL, "valid/ostree/ref"}: goodRef,
+	}
+	for in, expOut := range validCases {
+		out, err := ResolveRef(in.location, in.ref)
+		assert.NoError(t, err)
+		assert.Equal(t, expOut, out)
+	}
+
+	errCases := map[input]string{
+		{"not-a-url", "a-bad-ref"}:             "Get \"not-a-url/refs/heads/a-bad-ref\": unsupported protocol scheme \"\"",
+		{"http://0.0.0.0:10/repo", "whatever"}: "Get \"http://0.0.0.0:10/repo/refs/heads/whatever\": dial tcp 0.0.0.0:10: connect: connection refused",
+		{srv.URL, "rhel/8/x86_64/edge"}:        fmt.Sprintf("ostree repository \"%s/refs/heads/rhel/8/x86_64/edge\" returned status: 404 Not Found", srv.URL),
+		{srv.URL, "test_forbidden"}:            fmt.Sprintf("ostree repository \"%s/refs/heads/test_forbidden\" returned status: 403 Forbidden", srv.URL),
+		{srv.URL, "get_bad_ref"}:               fmt.Sprintf("ostree repository \"%s/refs/heads/get_bad_ref\" returned invalid reference", srv.URL),
+	}
+	for in, expMsg := range errCases {
+		_, err := ResolveRef(in.location, in.ref)
+		assert.EqualError(t, err, expMsg)
+	}
+}
+
+func TestVerifyRef(t *testing.T) {
+	cases := map[string]bool{
+		"a_perfectly_valid_ref": true,
+		"another/valid/ref":     true,
+		"this-one-has/all.the/_valid-/characters/even/_numbers_42": true,
+		"rhel/8/aarch64/edge": true,
+		"1337":                true,
+		"1337/but/also/more":  true,
+		"_good_start/ref":     true,
+		"/bad/ref":            false,
+		"invalid)characters":  false,
+		"this/was/doing/fine/until/the/very/end/": false,
+		"-bad_start/ref":     false,
+		".another/bad/start": false,
+		"how/about/now?":     false,
+	}
+
+	for in, expOut := range cases {
+		assert.Equal(t, expOut, VerifyRef(in), in)
+	}
+}
diff --git a/internal/weldr/api_test.go b/internal/weldr/api_test.go
index 82c7a6b47..b8c07403c 100644
--- a/internal/weldr/api_test.go
+++ b/internal/weldr/api_test.go
@@ -28,7 +28,6 @@ import (
 
 	"github.com/BurntSushi/toml"
 	"github.com/google/go-cmp/cmp"
-	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
@@ -1357,55 +1356,3 @@ func TestModulesList(t *testing.T) {
 		test.TestRoute(t, api, true, "GET", c.Path, ``, c.ExpectedStatus, c.ExpectedJSON)
 	}
 }
-
-func TestOstreeResolveRef(t *testing.T) {
-	goodRef := "5330bb1b8820944567f519de66ad6354c729b6b490dea1c5a7ba320c9f147c58"
-	badRef := "<html>not a ref</html>"
-
-	handler := http.NewServeMux()
-	handler.HandleFunc("/refs/heads/rhel/8/x86_64/edge", func(w http.ResponseWriter, r *http.Request) {
-		http.NotFound(w, r)
-	})
-	handler.HandleFunc("/refs/heads/test_forbidden", func(w http.ResponseWriter, r *http.Request) {
-		http.Error(w, "", http.StatusForbidden)
-	})
-	handler.HandleFunc("/refs/heads/get_bad_ref", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprint(w, badRef)
-	})
-
-	handler.HandleFunc("/refs/heads/test_redir", func(w http.ResponseWriter, r *http.Request) {
-		http.Redirect(w, r, "/refs/heads/valid/ostree/ref", http.StatusFound)
-	})
-	handler.HandleFunc("/refs/heads/valid/ostree/ref", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprint(w, goodRef)
-	})
-
-	srv := httptest.NewServer(handler)
-	defer srv.Close()
-
-	type input struct {
-		location string
-		ref      string
-	}
-	validCases := map[input]string{
-		{srv.URL, "test_redir"}:       goodRef,
-		{srv.URL, "valid/ostree/ref"}: goodRef,
-	}
-	for in, expOut := range validCases {
-		out, err := ostreeResolveRef(in.location, in.ref)
-		assert.NoError(t, err)
-		assert.Equal(t, expOut, out)
-	}
-
-	errCases := map[input]string{
-		{"not-a-url", "a-bad-ref"}:             "Get \"not-a-url/refs/heads/a-bad-ref\": unsupported protocol scheme \"\"",
-		{"http://0.0.0.0:10/repo", "whatever"}: "Get \"http://0.0.0.0:10/repo/refs/heads/whatever\": dial tcp 0.0.0.0:10: connect: connection refused",
-		{srv.URL, "rhel/8/x86_64/edge"}:        fmt.Sprintf("ostree repository \"%s/refs/heads/rhel/8/x86_64/edge\" returned status: 404 Not Found", srv.URL),
-		{srv.URL, "test_forbidden"}:            fmt.Sprintf("ostree repository \"%s/refs/heads/test_forbidden\" returned status: 403 Forbidden", srv.URL),
-		{srv.URL, "get_bad_ref"}:               fmt.Sprintf("ostree repository \"%s/refs/heads/get_bad_ref\" returned invalid reference", srv.URL),
-	}
-	for in, expMsg := range errCases {
-		_, err := ostreeResolveRef(in.location, in.ref)
-		assert.EqualError(t, err, expMsg)
-	}
-}
diff --git a/test/cases/api.sh b/test/cases/api.sh
index 5fb1004e9..f1f8d41cb 100755
--- a/test/cases/api.sh
+++ b/test/cases/api.sh
@@ -36,6 +36,7 @@ fi
 CLOUD_PROVIDER_AWS="aws"
 CLOUD_PROVIDER_GCP="gcp"
 CLOUD_PROVIDER_AZURE="azure"
+CLOUD_PROVIDER_AWS_S3="aws.s3"
 
 CLOUD_PROVIDER=${1:-$CLOUD_PROVIDER_AWS}
 
@@ -53,8 +54,15 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AZURE")
     echo "Testing Azure"
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    echo "Testing S3 bucket upload"
+    if [[ $ID != "rhel" ]]; then
+        echo "Skipped. S3 upload test is only tested on RHEL (testing only image type: rhel-edge-commit)."
+        exit 0
+    fi
+    ;;
   *)
-    echo "Unknown cloud provider '$CLOUD_PROVIDER'. Supported are '$CLOUD_PROVIDER_AWS', '$CLOUD_PROVIDER_GCP'"
+    echo "Unknown cloud provider '$CLOUD_PROVIDER'. Supported are '$CLOUD_PROVIDER_AWS', '$CLOUD_PROVIDER_AWS_S3', '$CLOUD_PROVIDER_GCP', '$CLOUD_PROVIDER_AZURE'"
     exit 1
     ;;
 esac
@@ -79,7 +87,7 @@ function checkEnvAzure() {
 }
 
 case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
+  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
     checkEnvAWS
     ;;
   "$CLOUD_PROVIDER_GCP")
@@ -112,6 +120,28 @@ function cleanupAWS() {
   fi
 }
 
+function cleanupAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # extract filename component from URL
+  local S3_FILENAME
+  S3_FILENAME=$(echo "${S3_URL}" | grep -oP '(?<=/)[^/]+(?=\?)')
+
+  # prepend bucket
+  local S3_URI
+  S3_URI="s3://${AWS_BUCKET}/${S3_FILENAME}"
+
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AWS_CMD="${AWS_CMD:-}"
+
+  if [ -n "$AWS_CMD" ]; then
+    set +e
+    $AWS_CMD s3 rm "${S3_URI}"
+    set -e
+  fi
+}
+
 function cleanupGCP() {
   # since this function can be called at any time, ensure that we don't expand unbound variables
   GCP_CMD="${GCP_CMD:-}"
@@ -149,6 +179,9 @@ function cleanup() {
     "$CLOUD_PROVIDER_AWS")
       cleanupAWS
       ;;
+    "$CLOUD_PROVIDER_AWS_S3")
+      cleanupAWSS3
+      ;;
     "$CLOUD_PROVIDER_GCP")
       cleanupGCP
       ;;
@@ -219,7 +252,7 @@ gpgkey=https://packages.microsoft.com/keys/microsoft.asc" | sudo tee /etc/yum.re
 }
 
 case $CLOUD_PROVIDER in
-  "$CLOUD_PROVIDER_AWS")
+  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
     installClientAWS
     ;;
   "$CLOUD_PROVIDER_GCP")
@@ -332,6 +365,40 @@ function createReqFileAWS() {
 EOF
 }
 
+function createReqFileAWSS3() {
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "packages": [
+      "postgresql"
+    ]
+  },
+  "image_requests": [
+    {
+      "architecture": "$ARCH",
+      "image_type": "rhel-edge-commit",
+      "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+      "ostree": {
+        "ref": "test/rhel/8/edge"
+      },
+      "upload_request": {
+          "type": "aws.s3",
+          "options": {
+            "region": "${AWS_REGION}",
+            "s3": {
+              "access_key_id": "${AWS_ACCESS_KEY_ID}",
+              "secret_access_key": "${AWS_SECRET_ACCESS_KEY}",
+              "bucket": "${AWS_BUCKET}"
+            }
+          }
+      }
+    }
+  ]
+}
+EOF
+}
+
 function createReqFileGCP() {
   # constrains for GCP resource IDs:
   # - max 62 characters
@@ -401,6 +468,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     createReqFileAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    createReqFileAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     createReqFileGCP
     ;;
@@ -481,6 +551,15 @@ function checkUploadStatusOptionsAWS() {
   test "$REGION" = "$AWS_REGION"
 }
 
+function checkUploadStatusOptionsAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # S3 URL contains region and bucket name
+  echo "$S3_URL" | grep -F "$AWS_BUCKET" -
+  echo "$S3_URL" | grep -F "$AWS_REGION" -
+}
+
 function checkUploadStatusOptionsGCP() {
   GCP_PROJECT=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS")
 
@@ -504,6 +583,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     checkUploadStatusOptionsAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    checkUploadStatusOptionsAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     checkUploadStatusOptionsGCP
     ;;
@@ -583,6 +665,19 @@ function verifyInAWS() {
   fi
 }
 
+
+# Verify tarball in AWS S3
+function verifyInAWSS3() {
+  local S3_URL
+  S3_URL=$(echo "$UPLOAD_OPTIONS" | jq -r '.url')
+
+  # Download the commit using the Presigned URL
+  curl "${S3_URL}" --output "${WORKDIR}/edge-commit.tar"
+
+  # Verify that the commit contains the ref we defined in the request
+  tar tvf "${WORKDIR}/edge-commit.tar" "repo/refs/heads/test/rhel/8/edge"
+}
+
 # Verify image in Compute Engine on GCP
 function verifyInGCP() {
   # Authenticate
@@ -650,6 +745,9 @@ case $CLOUD_PROVIDER in
   "$CLOUD_PROVIDER_AWS")
     verifyInAWS
     ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    verifyInAWSS3
+    ;;
   "$CLOUD_PROVIDER_GCP")
     verifyInGCP
     ;;