Repository: osbuild/osbuild-composer
Pull request #1893 (2021-10-07 21:01:07)

Configure AWS credentials in the worker


Patch:
diff --git a/cmd/osbuild-worker/jobimpl-osbuild.go b/cmd/osbuild-worker/jobimpl-osbuild.go
index a53dcef63..d5d25cd4c 100644
--- a/cmd/osbuild-worker/jobimpl-osbuild.go
+++ b/cmd/osbuild-worker/jobimpl-osbuild.go
@@ -28,6 +28,7 @@ type OSBuildJobImpl struct {
 	KojiServers map[string]koji.GSSAPICredentials
 	GCPCreds    []byte
 	AzureCreds  *azure.Credentials
+	AWSCreds    string
 }
 
 func appendTargetError(res *worker.OSBuildJobResult, err error) {
@@ -36,6 +37,17 @@ func appendTargetError(res *worker.OSBuildJobResult, err error) {
 	res.TargetErrors = append(res.TargetErrors, errStr)
 }
 
+// Returns an *awsupload.AWS object with the credentials of the request. If they
+// are not accessible, then try to use the one obtained in the worker
+// configuration.
+func (impl *OSBuildJobImpl) getAWS(region string, accessId string, secret string, token string) (*awsupload.AWS, error) {
+	if accessId != "" && secret != "" {
+		return awsupload.New(region, accessId, secret, token)
+	} else {
+		return awsupload.NewFromFile(impl.AWSCreds, region)
+	}
+}
+
 func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 	// Initialize variable needed for reporting back to osbuild-composer.
 	var osbuildJobResult *worker.OSBuildJobResult = &worker.OSBuildJobResult{
@@ -206,7 +218,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			osbuildJobResult.Success = true
 			osbuildJobResult.UploadStatus = "success"
 		case *target.AWSTargetOptions:
-			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
+			a, err := impl.getAWS(options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
 				return nil
@@ -242,7 +254,7 @@ func (impl *OSBuildJobImpl) Run(job worker.Job) error {
 			osbuildJobResult.Success = true
 			osbuildJobResult.UploadStatus = "success"
 		case *target.AWSS3TargetOptions:
-			a, err := awsupload.New(options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
+			a, err := impl.getAWS(options.Region, options.AccessKeyID, options.SecretAccessKey, options.SessionToken)
 			if err != nil {
 				appendTargetError(osbuildJobResult, err)
 				return nil
diff --git a/cmd/osbuild-worker/main.go b/cmd/osbuild-worker/main.go
index b52eaa249..b8aaaff79 100644
--- a/cmd/osbuild-worker/main.go
+++ b/cmd/osbuild-worker/main.go
@@ -97,6 +97,9 @@ func main() {
 		Azure *struct {
 			Credentials string `toml:"credentials"`
 		} `toml:"azure"`
+		AWS *struct {
+			Credentials string `toml:"credentials"`
+		} `toml:"aws"`
 		Authentication *struct {
 			OAuthURL         string `toml:"oauth_url"`
 			OfflineTokenPath string `toml:"offline_token"`
@@ -232,6 +235,14 @@ func main() {
 		}
 	}
 
+	// If the credentials are not provided in the configuration, then the
+	// worker will look in $HOME/.aws/credentials or at the file pointed by
+	// the "AWS_SHARED_CREDENTIALS_FILE" variable.
+	var awsCredentials = ""
+	if config.AWS != nil {
+		awsCredentials = config.AWS.Credentials
+	}
+
 	jobImpls := map[string]JobImplementation{
 		"osbuild": &OSBuildJobImpl{
 			Store:       store,
@@ -239,6 +250,7 @@ func main() {
 			KojiServers: kojiServers,
 			GCPCreds:    gcpCredentials,
 			AzureCreds:  azureCredentials,
+			AWSCreds:    awsCredentials,
 		},
 		"osbuild-koji": &OSBuildKojiJobImpl{
 			Store:       store,
diff --git a/internal/cloudapi/v2/errors.go b/internal/cloudapi/v2/errors.go
index 5a3c7f460..fa73fe3f0 100644
--- a/internal/cloudapi/v2/errors.go
+++ b/internal/cloudapi/v2/errors.go
@@ -24,7 +24,6 @@ const (
 	ErrorInvalidOSTreeRef        ServiceErrorCode = 9
 	ErrorInvalidOSTreeRepo       ServiceErrorCode = 10
 	ErrorFailedToMakeManifest    ServiceErrorCode = 11
-	ErrorMultiImageCompose       ServiceErrorCode = 13
 	ErrorInvalidComposeId        ServiceErrorCode = 14
 	ErrorComposeNotFound         ServiceErrorCode = 15
 	ErrorInvalidErrorId          ServiceErrorCode = 16
@@ -84,7 +83,6 @@ func getServiceErrors() serviceErrors {
 		serviceError{ErrorInvalidOSTreeRef, http.StatusBadRequest, "Invalid OSTree ref"},
 		serviceError{ErrorInvalidOSTreeRepo, http.StatusBadRequest, "Error resolving OSTree repo"},
 		serviceError{ErrorFailedToMakeManifest, http.StatusBadRequest, "Failed to get manifest"},
-		serviceError{ErrorMultiImageCompose, http.StatusBadRequest, "Only single-image composes are currently supported"},
 		serviceError{ErrorInvalidComposeId, http.StatusBadRequest, "Invalid format for compose id"},
 		serviceError{ErrorComposeNotFound, http.StatusNotFound, "Compose with given id not found"},
 		serviceError{ErrorInvalidErrorId, http.StatusBadRequest, "Invalid format for error id, it should be an integer as a string"},
diff --git a/internal/cloudapi/v2/openapi.v2.gen.go b/internal/cloudapi/v2/openapi.v2.gen.go
index a19dfa2ad..07225e93c 100644
--- a/internal/cloudapi/v2/openapi.v2.gen.go
+++ b/internal/cloudapi/v2/openapi.v2.gen.go
@@ -96,7 +96,7 @@ type ComposeRequest struct {
 	// Embedded fields due to inline allOf schema
 	Customizations *Customizations `json:"customizations,omitempty"`
 	Distribution   string          `json:"distribution"`
-	ImageRequests  []ImageRequest  `json:"image_requests"`
+	ImageRequest   ImageRequest    `json:"image_request"`
 }
 
 // ComposeStatus defines model for ComposeStatus.
@@ -478,65 +478,65 @@ func RegisterHandlers(router EchoRouter, si ServerInterface) {
 // Base64 encoded, gzipped, json marshaled Swagger object
 var swaggerSpec = []string{
 
-	"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",
+	"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",
 }
 
 // GetSwagger returns the Swagger specification corresponding to the generated code
diff --git a/internal/cloudapi/v2/openapi.v2.yml b/internal/cloudapi/v2/openapi.v2.yml
index a01574c26..64f7f2d84 100644
--- a/internal/cloudapi/v2/openapi.v2.yml
+++ b/internal/cloudapi/v2/openapi.v2.yml
@@ -474,15 +474,13 @@ components:
       - type: object
         required:
           - distribution
-          - image_requests
+          - image_request
         properties:
           distribution:
             type: string
             example: 'rhel-8'
-          image_requests:
-            type: array
-            items:
-              $ref: '#/components/schemas/ImageRequest'
+          image_request:
+            $ref: '#/components/schemas/ImageRequest'
           customizations:
             $ref: '#/components/schemas/Customizations'
     ImageRequest:
diff --git a/internal/cloudapi/v2/v2.go b/internal/cloudapi/v2/v2.go
index 75bf804e0..bb7ce6e98 100644
--- a/internal/cloudapi/v2/v2.go
+++ b/internal/cloudapi/v2/v2.go
@@ -160,13 +160,12 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 		}
 	}
 
-	type imageRequest struct {
+	var imageRequest struct {
 		manifest distro.Manifest
 		arch     string
 		exports  []string
+		target   *target.Target
 	}
-	imageRequests := make([]imageRequest, len(request.ImageRequests))
-	var targets []*target.Target
 
 	// use the same seed for all images so we get the same IDs
 	bigSeed, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
@@ -175,257 +174,248 @@ func (h *apiHandlers) PostCompose(ctx echo.Context) error {
 	}
 	manifestSeed := bigSeed.Int64()
 
-	for i, ir := range request.ImageRequests {
-		arch, err := distribution.GetArch(ir.Architecture)
-		if err != nil {
-			return HTTPError(ErrorUnsupportedArchitecture)
-		}
-		imageType, err := arch.GetImageType(imageTypeFromApiImageType(ir.ImageType))
-		if err != nil {
-			return HTTPError(ErrorUnsupportedImageType)
-		}
-		repositories := make([]rpmmd.RepoConfig, len(ir.Repositories))
-		for j, repo := range ir.Repositories {
-			repositories[j].RHSM = repo.Rhsm
-
-			if repo.Baseurl != nil {
-				repositories[j].BaseURL = *repo.Baseurl
-			} else if repo.Mirrorlist != nil {
-				repositories[j].MirrorList = *repo.Mirrorlist
-			} else if repo.Metalink != nil {
-				repositories[j].Metalink = *repo.Metalink
-			} else {
-				return HTTPError(ErrorInvalidRepository)
-			}
+	ir := request.ImageRequest
+	arch, err := distribution.GetArch(ir.Architecture)
+	if err != nil {
+		return HTTPError(ErrorUnsupportedArchitecture)
+	}
+	imageType, err := arch.GetImageType(imageTypeFromApiImageType(ir.ImageType))
+	if err != nil {
+		return HTTPError(ErrorUnsupportedImageType)
+	}
+	repositories := make([]rpmmd.RepoConfig, len(ir.Repositories))
+	for j, repo := range ir.Repositories {
+		repositories[j].RHSM = repo.Rhsm
+
+		if repo.Baseurl != nil {
+			repositories[j].BaseURL = *repo.Baseurl
+		} else if repo.Mirrorlist != nil {
+			repositories[j].MirrorList = *repo.Mirrorlist
+		} else if repo.Metalink != nil {
+			repositories[j].Metalink = *repo.Metalink
+		} else {
+			return HTTPError(ErrorInvalidRepository)
 		}
+	}
 
-		packageSets := imageType.PackageSets(bp)
-		depsolveJobID, err := h.server.workers.EnqueueDepsolve(&worker.DepsolveJob{
-			PackageSets:      packageSets,
-			Repos:            repositories,
-			ModulePlatformID: distribution.ModulePlatformID(),
-			Arch:             arch.Name(),
-			Releasever:       distribution.Releasever(),
-		})
+	packageSets := imageType.PackageSets(bp)
+	depsolveJobID, err := h.server.workers.EnqueueDepsolve(&worker.DepsolveJob{
+		PackageSets:      packageSets,
+		Repos:            repositories,
+		ModulePlatformID: distribution.ModulePlatformID(),
+		Arch:             arch.Name(),
+		Releasever:       distribution.Releasever(),
+	})
+	if err != nil {
+		return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+	}
+
+	var depsolveResults worker.DepsolveJobResult
+	for {
+		status, _, err := h.server.workers.JobStatus(depsolveJobID, &depsolveResults)
 		if err != nil {
-			return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
+			return HTTPErrorWithInternal(ErrorGettingDepsolveJobStatus, err)
 		}
-
-		var depsolveResults worker.DepsolveJobResult
-		for {
-			status, _, err := h.server.workers.JobStatus(depsolveJobID, &depsolveResults)
-			if err != nil {
-				return HTTPErrorWithInternal(ErrorGettingDepsolveJobStatus, err)
-			}
-			if status.Canceled {
-				return HTTPErrorWithInternal(ErrorDepsolveJobCanceled, err)
-			}
-			if !status.Finished.IsZero() {
-				break
-			}
-			time.Sleep(50 * time.Millisecond)
+		if status.Canceled {
+			return HTTPErrorWithInternal(ErrorDepsolveJobCanceled, err)
+		}
+		if !status.Finished.IsZero() {
+			break
 		}
+		time.Sleep(50 * time.Millisecond)
+	}
 
-		if depsolveResults.Error != "" {
-			if depsolveResults.ErrorType == worker.DepsolveErrorType {
-				return HTTPError(ErrorDNFError)
-			}
-			return HTTPErrorWithInternal(ErrorFailedToDepsolve, errors.New(depsolveResults.Error))
+	if depsolveResults.Error != "" {
+		if depsolveResults.ErrorType == worker.DepsolveErrorType {
+			return HTTPError(ErrorDNFError)
 		}
+		return HTTPErrorWithInternal(ErrorFailedToDepsolve, errors.New(depsolveResults.Error))
+	}
 
-		pkgSpecSets := depsolveResults.PackageSpecs
+	pkgSpecSets := depsolveResults.PackageSpecs
 
-		imageOptions := distro.ImageOptions{Size: imageType.Size(0)}
-		if request.Customizations != nil && request.Customizations.Subscription != nil {
-			imageOptions.Subscription = &distro.SubscriptionImageOptions{
-				Organization:  request.Customizations.Subscription.Organization,
-				ActivationKey: request.Customizations.Subscription.ActivationKey,
-				ServerUrl:     request.Customizations.Subscription.ServerUrl,
-				BaseUrl:       request.Customizations.Subscription.BaseUrl,
-				Insights:      request.Customizations.Subscription.Insights,
-			}
+	imageOptions := distro.ImageOptions{Size: imageType.Size(0)}
+	if request.Customizations != nil && request.Customizations.Subscription != nil {
+		imageOptions.Subscription = &distro.SubscriptionImageOptions{
+			Organization:  request.Customizations.Subscription.Organization,
+			ActivationKey: request.Customizations.Subscription.ActivationKey,
+			ServerUrl:     request.Customizations.Subscription.ServerUrl,
+			BaseUrl:       request.Customizations.Subscription.BaseUrl,
+			Insights:      request.Customizations.Subscription.Insights,
 		}
+	}
 
-		// 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) {
-			return HTTPError(ErrorInvalidOSTreeRef)
-		} else {
-			imageOptions.OSTree = distro.OSTreeImageOptions{Ref: *ostreeOptions.Ref}
+	// 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) {
+		return HTTPError(ErrorInvalidOSTreeRef)
+	} 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 {
+			return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, err)
 		}
+		imageOptions.OSTree.Parent = parent
+	}
 
-		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 {
-				return HTTPErrorWithInternal(ErrorInvalidOSTreeRepo, err)
-			}
-			imageOptions.OSTree.Parent = parent
-		}
-
-		// Set the blueprint customisation to take care of the user
-		var blueprintCustoms *blueprint.Customizations
-		if request.Customizations != nil && request.Customizations.Users != nil {
-			var userCustomizations []blueprint.UserCustomization
-			for _, user := range *request.Customizations.Users {
-				var groups []string
-				if user.Groups != nil {
-					groups = *user.Groups
-				} else {
-					groups = nil
-				}
-				userCustomizations = append(userCustomizations,
-					blueprint.UserCustomization{
-						Name:   user.Name,
-						Key:    user.Key,
-						Groups: groups,
-					},
-				)
-			}
-			blueprintCustoms = &blueprint.Customizations{
-				User: userCustomizations,
+	// Set the blueprint customisation to take care of the user
+	var blueprintCustoms *blueprint.Customizations
+	if request.Customizations != nil && request.Customizations.Users != nil {
+		var userCustomizations []blueprint.UserCustomization
+		for _, user := range *request.Customizations.Users {
+			var groups []string
+			if user.Groups != nil {
+				groups = *user.Groups
+			} else {
+				groups = nil
 			}
+			userCustomizations = append(userCustomizations,
+				blueprint.UserCustomization{
+					Name:   user.Name,
+					Key:    user.Key,
+					Groups: groups,
+				},
+			)
 		}
-
-		manifest, err := imageType.Manifest(blueprintCustoms, imageOptions, repositories, pkgSpecSets, manifestSeed)
-		if err != nil {
-			return HTTPErrorWithInternal(ErrorFailedToMakeManifest, err)
+		blueprintCustoms = &blueprint.Customizations{
+			User: userCustomizations,
 		}
+	}
 
-		imageRequests[i].manifest = manifest
-		imageRequests[i].arch = arch.Name()
-		imageRequests[i].exports = imageType.Exports()
-
-		/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
-		switch ir.ImageType {
-		case ImageTypes_aws:
-			var awsUploadOptions AWSEC2UploadOptions
-			jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONMarshallingError)
-			}
-			err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONUnMarshallingError)
-			}
+	manifest, err := imageType.Manifest(blueprintCustoms, imageOptions, repositories, pkgSpecSets, manifestSeed)
+	if err != nil {
+		return HTTPErrorWithInternal(ErrorFailedToMakeManifest, err)
+	}
 
-			key := fmt.Sprintf("composer-api-%s", uuid.New().String())
-			t := target.NewAWSTarget(&target.AWSTargetOptions{
-				Filename:          imageType.Filename(),
-				Region:            awsUploadOptions.Region,
-				Bucket:            h.server.awsBucket,
-				Key:               key,
-				ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
-			})
-			if awsUploadOptions.SnapshotName != nil {
-				t.ImageName = *awsUploadOptions.SnapshotName
-			} else {
-				t.ImageName = key
-			}
+	imageRequest.manifest = manifest
+	imageRequest.arch = arch.Name()
+	imageRequest.exports = imageType.Exports()
 
-			targets = append(targets, t)
-		case ImageTypes_edge_installer:
-			fallthrough
-		case ImageTypes_edge_commit:
-			var awsS3UploadOptions AWSS3UploadOptions
-			jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONMarshallingError)
-			}
-			err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONUnMarshallingError)
-			}
+	/* oneOf is not supported by the openapi generator so marshal and unmarshal the uploadrequest based on the type */
+	switch ir.ImageType {
+	case ImageTypes_aws:
+		var awsUploadOptions AWSEC2UploadOptions
+		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONMarshallingError)
+		}
+		err = json.Unmarshal(jsonUploadOptions, &awsUploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONUnMarshallingError)
+		}
 
-			key := fmt.Sprintf("composer-api-%s", uuid.New().String())
-			t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
-				Filename: imageType.Filename(),
-				Region:   awsS3UploadOptions.Region,
-				Bucket:   h.server.awsBucket,
-				Key:      key,
-			})
+		key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+		t := target.NewAWSTarget(&target.AWSTargetOptions{
+			Filename:          imageType.Filename(),
+			Region:            awsUploadOptions.Region,
+			Bucket:            h.server.awsBucket,
+			Key:               key,
+			ShareWithAccounts: awsUploadOptions.ShareWithAccounts,
+		})
+		if awsUploadOptions.SnapshotName != nil {
+			t.ImageName = *awsUploadOptions.SnapshotName
+		} else {
 			t.ImageName = key
+		}
 
-			targets = append(targets, t)
-		case ImageTypes_gcp:
-			var gcpUploadOptions GCPUploadOptions
-			jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONMarshallingError)
-			}
-			err = json.Unmarshal(jsonUploadOptions, &gcpUploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONUnMarshallingError)
-			}
+		imageRequest.target = t
+	case ImageTypes_edge_installer:
+		fallthrough
+	case ImageTypes_edge_commit:
+		var awsS3UploadOptions AWSS3UploadOptions
+		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONMarshallingError)
+		}
+		err = json.Unmarshal(jsonUploadOptions, &awsS3UploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONUnMarshallingError)
+		}
 
-			var share []string
-			if gcpUploadOptions.ShareWithAccounts != nil {
-				share = *gcpUploadOptions.ShareWithAccounts
-			}
+		key := fmt.Sprintf("composer-api-%s", uuid.New().String())
+		t := target.NewAWSS3Target(&target.AWSS3TargetOptions{
+			Filename: imageType.Filename(),
+			Region:   awsS3UploadOptions.Region,
+			Bucket:   h.server.awsBucket,
+			Key:      key,
+		})
+		t.ImageName = key
 
-			object := fmt.Sprintf("composer-api-%s", uuid.New().String())
-			t := target.NewGCPTarget(&target.GCPTargetOptions{
-				Filename:          imageType.Filename(),
-				Region:            gcpUploadOptions.Region,
-				Os:                "", // not exposed in cloudapi for now
-				Bucket:            gcpUploadOptions.Bucket,
-				Object:            object,
-				ShareWithAccounts: share,
-			})
-			// Import will fail if an image with this name already exists
-			if gcpUploadOptions.ImageName != nil {
-				t.ImageName = *gcpUploadOptions.ImageName
-			} else {
-				t.ImageName = object
-			}
+		imageRequest.target = t
+	case ImageTypes_gcp:
+		var gcpUploadOptions GCPUploadOptions
+		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONMarshallingError)
+		}
+		err = json.Unmarshal(jsonUploadOptions, &gcpUploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONUnMarshallingError)
+		}
 
-			targets = append(targets, t)
-		case ImageTypes_azure:
-			var azureUploadOptions AzureUploadOptions
-			jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONMarshallingError)
-			}
-			err = json.Unmarshal(jsonUploadOptions, &azureUploadOptions)
-			if err != nil {
-				return HTTPError(ErrorJSONUnMarshallingError)
-			}
-			t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
-				Filename:       imageType.Filename(),
-				TenantID:       azureUploadOptions.TenantId,
-				Location:       azureUploadOptions.Location,
-				SubscriptionID: azureUploadOptions.SubscriptionId,
-				ResourceGroup:  azureUploadOptions.ResourceGroup,
-			})
+		var share []string
+		if gcpUploadOptions.ShareWithAccounts != nil {
+			share = *gcpUploadOptions.ShareWithAccounts
+		}
 
-			if azureUploadOptions.ImageName != nil {
-				t.ImageName = *azureUploadOptions.ImageName
-			} else {
-				// if ImageName wasn't given, generate a random one
-				t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
-			}
+		object := fmt.Sprintf("composer-api-%s", uuid.New().String())
+		t := target.NewGCPTarget(&target.GCPTargetOptions{
+			Filename:          imageType.Filename(),
+			Region:            gcpUploadOptions.Region,
+			Os:                "", // not exposed in cloudapi for now
+			Bucket:            gcpUploadOptions.Bucket,
+			Object:            object,
+			ShareWithAccounts: share,
+		})
+		// Import will fail if an image with this name already exists
+		if gcpUploadOptions.ImageName != nil {
+			t.ImageName = *gcpUploadOptions.ImageName
+		} else {
+			t.ImageName = object
+		}
 
-			targets = append(targets, t)
-		default:
-			return HTTPError(ErrorUnsupportedImageType)
+		imageRequest.target = t
+	case ImageTypes_azure:
+		var azureUploadOptions AzureUploadOptions
+		jsonUploadOptions, err := json.Marshal(ir.UploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONMarshallingError)
 		}
-	}
+		err = json.Unmarshal(jsonUploadOptions, &azureUploadOptions)
+		if err != nil {
+			return HTTPError(ErrorJSONUnMarshallingError)
+		}
+		t := target.NewAzureImageTarget(&target.AzureImageTargetOptions{
+			Filename:       imageType.Filename(),
+			TenantID:       azureUploadOptions.TenantId,
+			Location:       azureUploadOptions.Location,
+			SubscriptionID: azureUploadOptions.SubscriptionId,
+			ResourceGroup:  azureUploadOptions.ResourceGroup,
+		})
 
-	var ir imageRequest
-	if len(imageRequests) == 1 {
-		// NOTE: the store currently does not support multi-image composes
-		ir = imageRequests[0]
-	} else {
-		return HTTPError(ErrorMultiImageCompose)
+		if azureUploadOptions.ImageName != nil {
+			t.ImageName = *azureUploadOptions.ImageName
+		} else {
+			// if ImageName wasn't given, generate a random one
+			t.ImageName = fmt.Sprintf("composer-api-%s", uuid.New().String())
+		}
+
+		imageRequest.target = t
+	default:
+		return HTTPError(ErrorUnsupportedImageType)
 	}
 
-	id, err := h.server.workers.EnqueueOSBuild(ir.arch, &worker.OSBuildJob{
-		Manifest: ir.manifest,
-		Targets:  targets,
-		Exports:  ir.exports,
+	id, err := h.server.workers.EnqueueOSBuild(imageRequest.arch, &worker.OSBuildJob{
+		Manifest: imageRequest.manifest,
+		Targets:  []*target.Target{imageRequest.target},
+		Exports:  imageRequest.exports,
 	})
 	if err != nil {
 		return HTTPErrorWithInternal(ErrorEnqueueingJob, err)
diff --git a/internal/upload/awsupload/awsupload.go b/internal/upload/awsupload/awsupload.go
index abdc06bad..80739b919 100644
--- a/internal/upload/awsupload/awsupload.go
+++ b/internal/upload/awsupload/awsupload.go
@@ -21,10 +21,8 @@ type AWS struct {
 	s3       *s3.S3
 }
 
-func New(region, accessKeyID, accessKey, sessionToken string) (*AWS, error) {
-	// Session credentials
-	creds := credentials.NewStaticCredentials(accessKeyID, accessKey, sessionToken)
-
+// 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) {
 	// Create a Session with a custom region
 	sess, err := session.NewSession(&aws.Config{
 		Credentials: creds,
@@ -41,6 +39,24 @@ func New(region, accessKeyID, accessKey, sessionToken string) (*AWS, error) {
 	}, nil
 }
 
+// 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)
+}
+
+// Initializes a new AWS object 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 NewFromFile(filename string, region string) (*AWS, error) {
+	return newAwsFromCreds(credentials.NewSharedCredentials(filename, "default"), region)
+}
+
 func (a *AWS) Upload(filename, bucket, key string) (*s3manager.UploadOutput, error) {
 	file, err := os.Open(filename)
 	if err != nil {


Test patch:
diff --git a/internal/cloudapi/v2/v2_test.go b/internal/cloudapi/v2/v2_test.go
index 653f6a567..11644984d 100644
--- a/internal/cloudapi/v2/v2_test.go
+++ b/internal/cloudapi/v2/v2_test.go
@@ -134,7 +134,7 @@ func TestCompose(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "unsupported_distro",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "aws.ec2",
 			"repositories": [{
@@ -145,7 +145,7 @@ func TestCompose(t *testing.T) {
 				"region": "eu-central-1",
 				"share_with_accounts": ["123456789012"]
 			}
-		 }]
+		 }
 	}`, test_distro.TestArch3Name), http.StatusBadRequest, `
 	{
 		"href": "/api/image-builder-composer/v2/errors/4",
@@ -159,7 +159,7 @@ func TestCompose(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "unsupported_arch",
 			"image_type": "aws",
 			"repositories": [{
@@ -169,7 +169,7 @@ func TestCompose(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName), http.StatusBadRequest, `
 	{
 		"href": "/api/image-builder-composer/v2/errors/5",
@@ -183,7 +183,7 @@ func TestCompose(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "unsupported_image_type",
 			"repositories": [{
@@ -193,7 +193,7 @@ func TestCompose(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusBadRequest, `
 	{
 		"href": "/api/image-builder-composer/v2/errors/6",
@@ -230,7 +230,7 @@ func TestCompose(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "aws",
 			"repositories": [{
@@ -240,7 +240,7 @@ func TestCompose(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -258,7 +258,7 @@ func TestComposeStatusSuccess(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "aws",
 			"repositories": [{
@@ -268,7 +268,7 @@ func TestComposeStatusSuccess(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -324,7 +324,7 @@ func TestComposeStatusFailure(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "aws",
 			"repositories": [{
@@ -334,7 +334,7 @@ func TestComposeStatusFailure(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -389,7 +389,7 @@ func TestComposeCustomizations(t *testing.T) {
 				"key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINrGKErMYi+MMUwuHaRAJmRLoIzRf2qD2dD5z0BTx/6x"
 			}]
 		},
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "aws",
 			"repositories": [{
@@ -399,7 +399,7 @@ func TestComposeCustomizations(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -417,7 +417,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -429,7 +429,7 @@ func TestImageTypes(t *testing.T) {
 				"snapshot_name": "name",
 				"share_with_accounts": ["123456789012","234567890123"]
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_aws)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -438,7 +438,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -448,7 +448,7 @@ func TestImageTypes(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_aws)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -457,7 +457,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -467,7 +467,7 @@ func TestImageTypes(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_edge_commit)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -476,7 +476,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -486,7 +486,7 @@ func TestImageTypes(t *testing.T) {
 			"upload_options": {
 				"region": "eu-central-1"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_edge_installer)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -495,7 +495,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -508,7 +508,7 @@ func TestImageTypes(t *testing.T) {
 				"resource_group": "ToucanResourceGroup",
 				"location": "westeurope"
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_azure)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
@@ -517,7 +517,7 @@ func TestImageTypes(t *testing.T) {
 	test.TestRoute(t, srv.Handler("/api/image-builder-composer/v2"), false, "POST", "/api/image-builder-composer/v2/compose", fmt.Sprintf(`
 	{
 		"distribution": "%s",
-		"image_requests":[{
+		"image_request":{
 			"architecture": "%s",
 			"image_type": "%s",
 			"repositories": [{
@@ -529,7 +529,7 @@ func TestImageTypes(t *testing.T) {
 				"bucket": "some-eu-bucket",
 				"share_with_accounts": ["user:alice@example.com"]
 			}
-		 }]
+		 }
 	}`, test_distro.TestDistroName, test_distro.TestArch3Name, string(v2.ImageTypes_gcp)), http.StatusCreated, `
 	{
 		"href": "/api/image-builder-composer/v2/compose",
diff --git a/test/cases/api_v2.sh b/test/cases/api_v2.sh
new file mode 100755
index 000000000..c8010a706
--- /dev/null
+++ b/test/cases/api_v2.sh
@@ -0,0 +1,735 @@
+#!/usr/bin/bash
+
+#
+# Test osbuild-composer's main API endpoint by building a sample image and
+# uploading it to the appropriate cloud provider. The test currently supports
+# AWS and GCP.
+#
+# This script sets `-x` and is meant to always be run like that. This is
+# simpler than adding extensive error reporting, which would make this script
+# considerably more complex. Also, the full trace this produces is very useful
+# for the primary audience: developers of osbuild-composer looking at the log
+# from a run on a remote continuous integration system.
+#
+
+set -euxo pipefail
+
+ARTIFACTS=ci-artifacts
+mkdir -p "${ARTIFACTS}"
+
+source /etc/os-release
+DISTRO_CODE="${DISTRO_CODE:-${ID}_${VERSION_ID//./}}"
+
+# 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
+
+#
+# Set up the database queue
+#
+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
+
+# Start the db
+sudo ${CONTAINER_RUNTIME} run -d --name osbuild-composer-db \
+    --health-cmd "pg_isready -U postgres -d osbuildcomposer" --health-interval 2s \
+    --health-timeout 2s --health-retries 10 \
+    -e POSTGRES_USER=postgres \
+    -e POSTGRES_PASSWORD=foobar \
+    -e POSTGRES_DB=osbuildcomposer \
+    -p 5432:5432 \
+    quay.io/osbuild/postgres:13-alpine
+
+# Dump the logs once to have a little more output
+sudo ${CONTAINER_RUNTIME} logs osbuild-composer-db
+
+# Initialize a module in a temp dir so we can get tern without introducing
+# vendoring inconsistency
+pushd "$(mktemp -d)"
+sudo dnf install -y go
+go mod init temp
+go get github.com/jackc/tern
+PGUSER=postgres PGPASSWORD=foobar PGDATABASE=osbuildcomposer PGHOST=localhost PGPORT=5432 \
+      go run github.com/jackc/tern migrate -m /usr/share/tests/osbuild-composer/schemas
+popd
+
+function configure_composer() {
+  cat <<EOF | sudo tee "/etc/osbuild-composer/osbuild-composer.toml"
+[koji]
+allowed_domains = [ "localhost", "client.osbuild.org" ]
+ca = "/etc/osbuild-composer/ca-crt.pem"
+[koji.aws_config]
+bucket = "${AWS_BUCKET}"
+[worker]
+allowed_domains = [ "localhost", "worker.osbuild.org" ]
+ca = "/etc/osbuild-composer/ca-crt.pem"
+pg_host = "localhost"
+pg_port = "5432"
+pg_database = "osbuildcomposer"
+pg_user = "postgres"
+pg_password = "foobar"
+pg_ssl_mode = "disable"
+EOF
+
+  sudo mkdir -p /etc/osbuild-worker
+  AWS_CREDS_FILE="/etc/osbuild-worker/credentials"
+  cat <<EOF | sudo tee "/etc/osbuild-worker/osbuild-worker.toml"
+[AWS]
+credentials="$AWS_CREDS_FILE"
+EOF
+  cat <<EOF | sudo tee "$AWS_CREDS_FILE"
+[default]
+aws_access_key_id = $AWS_ACCESS_KEY_ID
+aws_secret_access_key = $AWS_SECRET_ACCESS_KEY
+EOF
+  sudo systemctl restart osbuild-composer osbuild-composer-worker@*.service
+}
+
+#
+# Which cloud provider are we testing?
+#
+
+CLOUD_PROVIDER_AWS="aws"
+CLOUD_PROVIDER_GCP="gcp"
+CLOUD_PROVIDER_AZURE="azure"
+CLOUD_PROVIDER_AWS_S3="aws.s3"
+
+CLOUD_PROVIDER=${1:-$CLOUD_PROVIDER_AWS}
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS")
+    echo "Testing AWS"
+    configure_composer
+    ;;
+  "$CLOUD_PROVIDER_GCP")
+    exit 0
+    ;;
+  "$CLOUD_PROVIDER_AZURE")
+    exit 0
+    ;;
+  "$CLOUD_PROVIDER_AWS_S3")
+    exit 0
+    ;;
+  *)
+    echo "Unknown cloud provider '$CLOUD_PROVIDER'. Supported are '$CLOUD_PROVIDER_AWS', '$CLOUD_PROVIDER_AWS_S3', '$CLOUD_PROVIDER_GCP', '$CLOUD_PROVIDER_AZURE'"
+    exit 1
+    ;;
+esac
+
+#
+# Verify that this script is running in the right environment.
+#
+
+# Check that needed variables are set to access AWS.
+function checkEnvAWS() {
+  printenv AWS_REGION AWS_BUCKET AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_API_TEST_SHARE_ACCOUNT > /dev/null
+}
+
+# Check that needed variables are set to register to RHSM (RHEL only)
+function checkEnvSubscription() {
+  printenv API_TEST_SUBSCRIPTION_ORG_ID API_TEST_SUBSCRIPTION_ACTIVATION_KEY > /dev/null
+}
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS" | "$CLOUD_PROVIDER_AWS_S3")
+    checkEnvAWS
+    ;;
+esac
+[[ "$ID" == "rhel" ]] && checkEnvSubscription
+
+#
+# Create a temporary directory and ensure it gets deleted when this script
+# terminates in any way.
+#
+
+function cleanupAWS() {
+  # since this function can be called at any time, ensure that we don't expand unbound variables
+  AWS_CMD="${AWS_CMD:-}"
+  AWS_INSTANCE_ID="${AWS_INSTANCE_ID:-}"
+  AMI_IMAGE_ID="${AMI_IMAGE_ID:-}"
+  AWS_SNAPSHOT_ID="${AWS_SNAPSHOT_ID:-}"
+
+  if [ -n "$AWS_CMD" ]; then
+    set +e
+    $AWS_CMD ec2 terminate-instances --instance-ids "$AWS_INSTANCE_ID"
+    $AWS_CMD ec2 deregister-image --image-id "$AMI_IMAGE_ID"
+    $AWS_CMD ec2 delete-snapshot --snapshot-id "$AWS_SNAPSHOT_ID"
+    $AWS_CMD ec2 delete-key-pair --key-name "key-for-$AMI_IMAGE_ID"
+    set -e
+  fi
+}
+
+function cleanupAWSS3() {
+  echo "mock cleanup"
+}
+
+function cleanupGCP() {
+  echo "mock cleanup"
+}
+
+function cleanupAzure() {
+  echo "mock cleanup"
+}
+
+WORKDIR=$(mktemp -d)
+KILL_PIDS=()
+function cleanup() {
+  case $CLOUD_PROVIDER in
+    "$CLOUD_PROVIDER_AWS")
+      cleanupAWS
+      ;;
+    "$CLOUD_PROVIDER_AWS_S3")
+      cleanupAWSS3
+      ;;
+    "$CLOUD_PROVIDER_GCP")
+      cleanupGCP
+      ;;
+    "$CLOUD_PROVIDER_AZURE")
+      cleanupAzure
+      ;;
+  esac
+
+  sudo rm -rf "$WORKDIR"
+
+  for P in "${KILL_PIDS[@]}"; do
+      sudo pkill -P "$P"
+  done
+}
+trap cleanup EXIT
+
+#
+# Install the necessary cloud provider client tools
+#
+
+function installClientAWS() {
+  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=${AWS_ACCESS_KEY_ID} \
+      -e AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} \
+      -v ${WORKDIR}:${WORKDIR}:Z \
+      ${CONTAINER_IMAGE_CLOUD_TOOLS} aws --region $AWS_REGION --output json --color on"
+  else
+    echo "Using pre-installed 'aws' from the system"
+    AWS_CMD="aws --region $AWS_REGION --output json --color on"
+  fi
+  $AWS_CMD --version
+}
+
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS" )
+    installClientAWS
+    ;;
+esac
+
+#
+# Make sure /openapi.json and /version endpoints return success
+#
+
+curl \
+    --silent \
+    --show-error \
+    --cacert /etc/osbuild-composer/ca-crt.pem \
+    --key /etc/osbuild-composer/client-key.pem \
+    --cert /etc/osbuild-composer/client-crt.pem \
+    https://localhost/api/image-builder-composer/v2/openapi | jq .
+
+#
+# Prepare a request to be sent to the composer API.
+#
+
+REQUEST_FILE="${WORKDIR}/request.json"
+ARCH=$(uname -m)
+SSH_USER=
+
+case $(set +x; . /etc/os-release; echo "$ID-$VERSION_ID") in
+  "rhel-9.0")
+    DISTRO="rhel-90"
+    if [[ "$CLOUD_PROVIDER" == "$CLOUD_PROVIDER_AWS" ]]; then
+      SSH_USER="ec2-user"
+    else
+      SSH_USER="cloud-user"
+    fi
+    ;;
+  "rhel-8.5")
+    DISTRO="rhel-85"
+    if [[ "$CLOUD_PROVIDER" == "$CLOUD_PROVIDER_AWS" ]]; then
+      SSH_USER="ec2-user"
+    else
+      SSH_USER="cloud-user"
+    fi
+    ;;
+  "rhel-8.4")
+    DISTRO="rhel-84"
+    SSH_USER="cloud-user"
+    ;;
+  "rhel-8.2" | "rhel-8.3")
+    DISTRO="rhel-8"
+    SSH_USER="cloud-user"
+    ;;
+  "fedora-33")
+    DISTRO="fedora-33"
+    SSH_USER="fedora"
+    ;;
+  "centos-8")
+    DISTRO="centos-8"
+    SSH_USER="cloud-user"
+    ;;
+esac
+
+# Only RHEL need subscription block.
+if [[ "$ID" == "rhel" ]]; then
+  SUBSCRIPTION_BLOCK=$(cat <<EndOfMessage
+,
+    "subscription": {
+      "organization": "${API_TEST_SUBSCRIPTION_ORG_ID:-}",
+      "activation_key": "${API_TEST_SUBSCRIPTION_ACTIVATION_KEY:-}",
+      "base_url": "https://cdn.redhat.com/",
+      "server_url": "subscription.rhsm.redhat.com",
+      "insights": true
+    }
+EndOfMessage
+)
+else
+  SUBSCRIPTION_BLOCK=''
+fi
+
+# generate a temp key for user tests
+ssh-keygen -t rsa -f /tmp/usertest -C "usertest" -N ""
+
+function createReqFileAWS() {
+  AWS_SNAPSHOT_NAME=$(uuidgen)
+
+  cat > "$REQUEST_FILE" << EOF
+{
+  "distribution": "$DISTRO",
+  "customizations": {
+    "packages": [
+      "postgresql"
+    ]${SUBSCRIPTION_BLOCK},
+    "users":[
+      {
+        "name": "user1",
+        "groups": ["wheel"],
+        "key": "$(cat /tmp/usertest.pub)"
+      },
+      {
+        "name": "user2",
+        "key": "$(cat /tmp/usertest.pub)"
+      }
+    ]
+  },
+  "image_requests": [
+    {
+      "architecture": "$ARCH",
+      "image_type": "aws",
+      "repositories": $(jq ".\"$ARCH\"" /usr/share/tests/osbuild-composer/repositories/"$DISTRO".json),
+      "upload_options": {
+        "region": "${AWS_REGION}",
+        "share_with_accounts": ["${AWS_API_TEST_SHARE_ACCOUNT}"],
+        "snapshot_name": "${AWS_SNAPSHOT_NAME}"
+      }
+    }
+  ]
+}
+EOF
+}
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS")
+    createReqFileAWS
+    ;;
+esac
+
+#
+# Send the request and wait for the job to finish.
+#
+# Separate `curl` and `jq` commands here, because piping them together hides
+# the server's response in case of an error.
+#
+
+function collectMetrics(){
+    METRICS_OUTPUT=$(curl \
+                          --cacert /etc/osbuild-composer/ca-crt.pem \
+                          --key /etc/osbuild-composer/client-key.pem \
+                          --cert /etc/osbuild-composer/client-crt.pem \
+                          https://localhost/metrics)
+
+    echo "$METRICS_OUTPUT" | grep "^total_compose_requests" | cut -f2 -d' '
+}
+
+function sendCompose() {
+
+    if [[ "$ID" == "rhel" ]]; then
+        echo "rhel --------------------------------- "
+        cat "$REQUEST_FILE"
+        echo "rhel --------------------------------- "
+    fi
+    OUTPUT=$(curl \
+                 --silent \
+                 --show-error \
+                 --cacert /etc/osbuild-composer/ca-crt.pem \
+                 --key /etc/osbuild-composer/client-key.pem \
+                 --cert /etc/osbuild-composer/client-crt.pem \
+                 --header 'Content-Type: application/json' \
+                 --request POST \
+                 --data @"$REQUEST_FILE" \
+                 https://localhost/api/image-builder-composer/v2/compose)
+
+    COMPOSE_ID=$(echo "$OUTPUT" | jq -r '.id')
+}
+
+function waitForState() {
+    local DESIRED_STATE="${1:-success}"
+    while true
+    do
+        OUTPUT=$(curl \
+                     --silent \
+                     --show-error \
+                     --cacert /etc/osbuild-composer/ca-crt.pem \
+                     --key /etc/osbuild-composer/client-key.pem \
+                     --cert /etc/osbuild-composer/client-crt.pem \
+                     https://localhost/api/image-builder-composer/v2/compose/"$COMPOSE_ID")
+
+        COMPOSE_STATUS=$(echo "$OUTPUT" | jq -r '.image_status.status')
+        UPLOAD_STATUS=$(echo "$OUTPUT" | jq -r '.image_status.upload_status.status')
+        UPLOAD_TYPE=$(echo "$OUTPUT" | jq -r '.image_status.upload_status.type')
+        UPLOAD_OPTIONS=$(echo "$OUTPUT" | jq -r '.image_status.upload_status.options')
+
+        case "$COMPOSE_STATUS" in
+            "$DESIRED_STATE")
+                break
+                ;;
+            # all valid status values for a compose which hasn't finished yet
+            "pending"|"building"|"uploading"|"registering")
+                ;;
+            # default undesired state
+            "failure")
+                echo "Image compose failed"
+                exit 1
+                ;;
+            *)
+                echo "API returned unexpected image_status.status value: '$COMPOSE_STATUS'"
+                exit 1
+                ;;
+        esac
+
+        sleep 30
+    done
+}
+
+sendCompose
+
+# crashed/stopped/killed worker should result in a failed state
+waitForState "building"
+sudo systemctl stop "osbuild-worker@*"
+waitForState "failure"
+sudo systemctl start "osbuild-worker@1"
+
+# full integration case
+INIT_COMPOSES="$(collectMetrics)"
+sendCompose
+waitForState
+SUBS_COMPOSES="$(collectMetrics)"
+
+test "$UPLOAD_STATUS" = "success"
+test "$UPLOAD_TYPE" = "$CLOUD_PROVIDER"
+test $((INIT_COMPOSES+1)) = "$SUBS_COMPOSES"
+
+
+# Make sure we get 1 job entry in the db per compose
+sudo podman exec osbuild-composer-db psql -U postgres -d osbuildcomposer -c "SELECT COUNT(*) FROM jobs;"
+
+#
+# Save the Manifest from the osbuild-composer store
+# NOTE: The rest of the job data can contain sensitive information
+#
+# Suppressing shellcheck.  See https://github.com/koalaman/shellcheck/wiki/SC2024#exceptions
+sudo podman exec osbuild-composer-db psql -U postgres -d osbuildcomposer -c "SELECT args->>'Manifest' FROM jobs" | sudo tee "${ARTIFACTS}/manifest.json"
+
+#
+# Verify the Cloud-provider specific upload_status options
+#
+
+function checkUploadStatusOptionsAWS() {
+  local AMI
+  AMI=$(echo "$UPLOAD_OPTIONS" | jq -r '.ami')
+  local REGION
+  REGION=$(echo "$UPLOAD_OPTIONS" | jq -r '.region')
+
+  # AWS ID consist of resource identifier followed by a 17-character string
+  echo "$AMI" | grep -e 'ami-[[:alnum:]]\{17\}' -
+  test "$REGION" = "$AWS_REGION"
+}
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS")
+    checkUploadStatusOptionsAWS
+    ;;
+esac
+
+#
+# Verify the image landed in the appropriate cloud provider, and delete it.
+#
+
+# Reusable function, which waits for a given host to respond to SSH
+function _instanceWaitSSH() {
+  local HOST="$1"
+
+  for LOOP_COUNTER in {0..30}; do
+      if ssh-keyscan "$HOST" > /dev/null 2>&1; then
+          echo "SSH is up!"
+          # ssh-keyscan "$PUBLIC_IP" | sudo tee -a /root/.ssh/known_hosts
+          break
+      fi
+      echo "Retrying in 5 seconds... $LOOP_COUNTER"
+      sleep 5
+  done
+}
+
+function _instanceCheck() {
+  echo "✔️ Instance checking"
+  local _ssh="$1"
+
+  # Check if postgres is installed
+  $_ssh rpm -q postgresql
+
+  # Verify subscribe status. Loop check since the system may not be registered such early(RHEL only)
+  if [[ "$ID" == "rhel" ]]; then
+    set +eu
+    for LOOP_COUNTER in {1..10}; do
+        subscribe_org_id=$($_ssh sudo subscription-manager identity | grep 'org ID')
+        if [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]; then
+            echo "System is subscribed."
+            break
+        else
+            echo "System is not subscribed. Retrying in 30 seconds...($LOOP_COUNTER/10)"
+            sleep 30
+        fi
+    done
+    set -eu
+    [[ "$subscribe_org_id" == "org ID: $API_TEST_SUBSCRIPTION_ORG_ID" ]]
+
+    # Unregister subscription
+    $_ssh sudo subscription-manager unregister
+  else
+    echo "Not RHEL OS. Skip subscription check."
+  fi
+}
+
+# Verify image in EC2 on AWS
+function verifyInAWS() {
+  $AWS_CMD ec2 describe-images \
+    --owners self \
+    --filters Name=name,Values="$AWS_SNAPSHOT_NAME" \
+    > "$WORKDIR/ami.json"
+
+  AMI_IMAGE_ID=$(jq -r '.Images[].ImageId' "$WORKDIR/ami.json")
+  AWS_SNAPSHOT_ID=$(jq -r '.Images[].BlockDeviceMappings[].Ebs.SnapshotId' "$WORKDIR/ami.json")
+  SHARE_OK=1
+
+  # Verify that the ec2 snapshot was shared
+  $AWS_CMD ec2 describe-snapshot-attribute --snapshot-id "$AWS_SNAPSHOT_ID" --attribute createVolumePermission > "$WORKDIR/snapshot-attributes.json"
+
+  SHARED_ID=$(jq -r '.CreateVolumePermissions[0].UserId' "$WORKDIR/snapshot-attributes.json")
+  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
+    SHARE_OK=0
+  fi
+
+  # Verify that the ec2 ami was shared
+  $AWS_CMD ec2 describe-image-attribute --image-id "$AMI_IMAGE_ID" --attribute launchPermission > "$WORKDIR/ami-attributes.json"
+
+  SHARED_ID=$(jq -r '.LaunchPermissions[0].UserId' "$WORKDIR/ami-attributes.json")
+  if [ "$AWS_API_TEST_SHARE_ACCOUNT" != "$SHARED_ID" ]; then
+    SHARE_OK=0
+  fi
+
+  if [ "$SHARE_OK" != 1 ]; then
+    echo "EC2 snapshot wasn't shared with the AWS_API_TEST_SHARE_ACCOUNT. 😢"
+    exit 1
+  fi
+
+  # Create key-pair
+  $AWS_CMD ec2 create-key-pair --key-name "key-for-$AMI_IMAGE_ID" --query 'KeyMaterial' --output text > keypair.pem
+  chmod 400 ./keypair.pem
+
+  # Create an instance based on the ami
+  $AWS_CMD ec2 run-instances --image-id "$AMI_IMAGE_ID" --count 1 --instance-type t2.micro --key-name "key-for-$AMI_IMAGE_ID" > "$WORKDIR/instances.json"
+  AWS_INSTANCE_ID=$(jq -r '.Instances[].InstanceId' "$WORKDIR/instances.json")
+
+  $AWS_CMD ec2 wait instance-running --instance-ids "$AWS_INSTANCE_ID"
+
+  $AWS_CMD ec2 describe-instances --instance-ids "$AWS_INSTANCE_ID" > "$WORKDIR/instances.json"
+  HOST=$(jq -r '.Reservations[].Instances[].PublicIpAddress' "$WORKDIR/instances.json")
+
+  echo "⏱ Waiting for AWS instance to respond to ssh"
+  _instanceWaitSSH "$HOST"
+
+  # Verify image
+  _ssh="ssh -oStrictHostKeyChecking=no -i ./keypair.pem $SSH_USER@$HOST"
+  _instanceCheck "$_ssh"
+
+  # Check access to user1 and user2
+  check_groups=$(ssh -i /tmp/usertest "user1@$HOST" -t 'groups')
+  if [[ $check_groups =~ "wheel" ]]; then
+   echo "✔️  user1 has the group wheel"
+  else
+    echo 'user1 should have the group wheel 😢'
+    exit 1
+  fi
+  check_groups=$(ssh -i /tmp/usertest "user2@$HOST" -t 'groups')
+  if [[ $check_groups =~ "wheel" ]]; then
+    echo 'user2 should not have group wheel 😢'
+    exit 1
+  else
+   echo "✔️  user2 does not have the group wheel"
+  fi
+}
+
+
+case $CLOUD_PROVIDER in
+  "$CLOUD_PROVIDER_AWS")
+    verifyInAWS
+    ;;
+esac
+
+# Verify selected package (postgresql) is included in package list
+function verifyPackageList() {
+  # Save build metadata to artifacts directory for troubleshooting
+  curl --silent \
+      --show-error \
+      --cacert /etc/osbuild-composer/ca-crt.pem \
+      --key /etc/osbuild-composer/client-key.pem \
+      --cert /etc/osbuild-composer/client-crt.pem \
+      https://localhost/api/image-builder-composer/v2/compose/"$COMPOSE_ID"/metadata --output "${ARTIFACTS}/metadata.json"
+  local PACKAGENAMES
+  PACKAGENAMES=$(jq -rM '.packages[].name' "${ARTIFACTS}/metadata.json")
+
+  if ! grep -q postgresql <<< "${PACKAGENAMES}"; then
+      echo "'postgresql' not found in compose package list 😠"
+      exit 1
+  fi
+}
+
+verifyPackageList
+
+#
+# Make sure that requesting a non existing paquet returns a 400 error
+#
+REQUEST_FILE2="${WORKDIR}/request2.json"
+jq '.customizations.packages = [ "jesuisunpaquetquinexistepas" ]' "$REQUEST_FILE" > "$REQUEST_FILE2"
+
+[ "$(curl \
+    --silent \
+    --cacert /etc/osbuild-composer/ca-crt.pem \
+    --key /etc/osbuild-composer/client-key.pem \
+    --cert /etc/osbuild-composer/client-crt.pem \
+    --output /dev/null \
+    --write-out '%{http_code}' \
+    -H "Content-Type: application/json" \
+    --data @"$REQUEST_FILE2" \
+    https://localhost/api/image-builder-composer/v2/compose)" = "400" ]
+
+#
+# Make sure that a request that makes the dnf-json crash returns a 500 error
+#
+sudo cp -f /usr/libexec/osbuild-composer/dnf-json /usr/libexec/osbuild-composer/dnf-json.bak
+sudo cat << EOF | sudo tee /usr/libexec/osbuild-composer/dnf-json
+#!/usr/bin/python3
+raise Exception()
+EOF
+[ "$(curl \
+    --silent \
+    --cacert /etc/osbuild-composer/ca-crt.pem \
+    --key /etc/osbuild-composer/client-key.pem \
+    --cert /etc/osbuild-composer/client-crt.pem \
+    --output /dev/null \
+    --write-out '%{http_code}' \
+    -H "Content-Type: application/json" \
+    --data @"$REQUEST_FILE2" \
+    https://localhost/api/image-builder-composer/v2/compose)" = "500" ]
+
+sudo mv -f /usr/libexec/osbuild-composer/dnf-json.bak /usr/libexec/osbuild-composer/dnf-json
+
+#
+# Verify oauth2
+#
+cat <<EOF | sudo tee "/etc/osbuild-composer/osbuild-composer.toml"
+[koji]
+enable_tls = false
+enable_mtls = false
+enable_jwt = true
+jwt_keys_url = "https://localhost:8080/certs"
+jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
+jwt_acl_file = ""
+
+[worker]
+allowed_domains = [ "localhost", "worker.osbuild.org" ]
+ca = "/etc/osbuild-composer/ca-crt.pem"
+pg_host = "localhost"
+pg_port = "5432"
+pg_database = "osbuildcomposer"
+pg_user = "postgres"
+pg_password = "foobar"
+pg_ssl_mode = "disable"
+enable_jwt = true
+jwt_keys_url = "https://localhost:8080/certs"
+jwt_ca_file = "/etc/osbuild-composer/ca-crt.pem"
+EOF
+
+cat <<EOF | sudo tee "/etc/osbuild-worker/token"
+offlineToken
+EOF
+
+cat <<EOF | sudo tee "/etc/osbuild-worker/osbuild-worker.toml"
+[authentication]
+oauth_url = http://localhost:8081/token
+offline_token = "/etc/osbuild-worker/token"
+EOF
+
+# Spin up an https instance for the composer-api and worker-api; the auth handler needs to hit an ssl `/certs` endpoint
+sudo /usr/libexec/osbuild-composer-test/osbuild-mock-openid-provider -rsaPubPem /etc/osbuild-composer/client-crt.pem -rsaPem /etc/osbuild-composer/client-key.pem -cert /etc/osbuild-composer/composer-crt.pem -key /etc/osbuild-composer/composer-key.pem &
+KILL_PIDS+=("$!")
+# Spin up an http instance for the worker client to bypass the need to specify an extra CA
+sudo /usr/libexec/osbuild-composer-test/osbuild-mock-openid-provider -a localhost:8081 -rsaPubPem /etc/osbuild-composer/client-crt.pem -rsaPem /etc/osbuild-composer/client-key.pem &
+KILL_PIDS+=("$!")
+
+sudo systemctl restart osbuild-composer
+
+until curl --output /dev/null --silent --fail localhost:8081/token; do
+    sleep 0.5
+done
+TOKEN="$(curl localhost:8081/token | jq -r .access_token)"
+
+[ "$(curl \
+        --silent \
+        --output /dev/null \
+        --write-out '%{http_code}' \
+        --header "Authorization: Bearer $TOKEN" \
+        http://localhost:443/api/image-builder-composer/v2/openapi)" = "200" ]
+
+[ "$(curl \
+        --silent \
+        --output /dev/null \
+        --write-out '%{http_code}' \
+        --header "Authorization: Bearer badtoken" \
+        http://localhost:443/api/image-builder-composer/v2/openapi)" = "401" ]
+
+sudo systemctl start osbuild-remote-worker@https:--localhost:8700.service
+sudo systemctl is-active --quiet osbuild-remote-worker@https:--localhost:8700.service
+
+exit 0