Repository: aws/amazon-ecs-cli-v2
Pull request #290 (2019-11-07 06:57:44)

Add implementation for Project Resources
This adds the ability to get project resources by
reading the output of project resource cloudformation stacks.

<!-- Provide summary of changes -->

<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.



Patch:
diff --git a/internal/pkg/aws/ecr/ecr.go b/internal/pkg/aws/ecr/ecr.go
new file mode 100644
index 00000000..47bf9449
--- /dev/null
+++ b/internal/pkg/aws/ecr/ecr.go
@@ -0,0 +1,32 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package ecr contains utility functions for dealing with ECR repos
+package ecr
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+)
+
+const (
+	urlFmtString      = "%s.dkr.ecr.%s.amazonaws.com/%s"
+	arnResourcePrefix = "repository/"
+)
+
+// URIFromARN converts an ECR Repo ARN to a Repository URI
+func URIFromARN(repositoryARN string) (string, error) {
+	repoARN, err := arn.Parse(repositoryARN)
+	if err != nil {
+		return "", fmt.Errorf("parsing repository ARN %s: %w", repositoryARN, err)
+	}
+	// Repo ARNs look like arn:aws:ecr:region:012345678910:repository/test
+	// so we have to strip the repository out.
+	repoName := strings.TrimPrefix(repoARN.Resource, arnResourcePrefix)
+	return fmt.Sprintf(urlFmtString,
+		repoARN.AccountID,
+		repoARN.Region,
+		repoName), nil
+}
diff --git a/internal/pkg/deploy/cloudformation/cloudformation.go b/internal/pkg/deploy/cloudformation/cloudformation.go
index 3e0ad505..556b52e2 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation.go
@@ -26,12 +26,6 @@ const (
 	appTagKey     = "ecs-application"
 )
 
-// CloudFormation wraps the CloudFormationAPI interface
-type CloudFormation struct {
-	client cloudformationiface.CloudFormationAPI
-	box    packd.Box
-}
-
 type stackConfiguration interface {
 	StackName() string
 	Template() (string, error)
@@ -39,11 +33,37 @@ type stackConfiguration interface {
 	Tags() []*cloudformation.Tag
 }
 
+// regionalClientProvider lets us make cross region describe calls
+// in one CloudFormation struct. We can dynamically generate clients
+// configured for a specific region.
+type regionalClientProvider interface {
+	Client(string) cloudformationiface.CloudFormationAPI
+}
+
+type cfClientBuilder struct {
+	session *session.Session
+}
+
+func (cf cfClientBuilder) Client(region string) cloudformationiface.CloudFormationAPI {
+	return cloudformation.New(cf.session, &aws.Config{Region: aws.String(region)})
+}
+
+// CloudFormation wraps the CloudFormationAPI interface
+type CloudFormation struct {
+	regionalClientProvider regionalClientProvider
+	client                 cloudformationiface.CloudFormationAPI
+	box                    packd.Box
+}
+
 // New returns a configured CloudFormation client.
 func New(sess *session.Session) CloudFormation {
+	cb := cfClientBuilder{
+		session: sess,
+	}
 	return CloudFormation{
-		client: cloudformation.New(sess),
-		box:    templates.Box(),
+		regionalClientProvider: cb,
+		client:                 cb.Client(*sess.Config.Region),
+		box:                    templates.Box(),
 	}
 }
 
@@ -188,13 +208,24 @@ func (cf CloudFormation) waitForStackCreation(stackConfig stackConfiguration) (*
 		return nil, fmt.Errorf("failed to create stack %s: %w", stackConfig.StackName(), err)
 	}
 
-	describeStackOutput, err := cf.client.DescribeStacks(describeStackInput)
+	return cf.describeStack(describeStackInput)
+}
+
+func (cf CloudFormation) describeStack(describeStackInput *cloudformation.DescribeStacksInput) (*cloudformation.Stack, error) {
+	return cf.describeStackWithClient(describeStackInput, cf.client)
+}
+
+// describeStackWithClient let's us use a preconfigured client to make calls to CloudFormation.
+// This is useful when we need to make cross-region calls.
+func (cf CloudFormation) describeStackWithClient(describeStackInput *cloudformation.DescribeStacksInput,
+	client cloudformationiface.CloudFormationAPI) (*cloudformation.Stack, error) {
+	describeStackOutput, err := client.DescribeStacks(describeStackInput)
 	if err != nil {
 		return nil, err
 	}
 
 	if len(describeStackOutput.Stacks) == 0 {
-		return nil, fmt.Errorf("failed to find a stack named %s after it was created", stackConfig.StackName())
+		return nil, fmt.Errorf("failed to find a stack named %s", *describeStackInput.StackName)
 	}
 
 	return describeStackOutput.Stacks[0], nil
diff --git a/internal/pkg/deploy/cloudformation/project.go b/internal/pkg/deploy/cloudformation/project.go
index 8b5cd686..28c3dd4a 100644
--- a/internal/pkg/deploy/cloudformation/project.go
+++ b/internal/pkg/deploy/cloudformation/project.go
@@ -64,6 +64,72 @@ func (cf CloudFormation) DeployProject(in *deploy.CreateProjectInput) error {
 	return nil
 }
 
+// GetProjectResourcesByRegion fetches all the regional resources for a particular region.
+func (cf CloudFormation) GetProjectResourcesByRegion(project *archer.Project, region string) (*archer.ProjectRegionalResources, error) {
+	resources, err := cf.getResourcesForStackInstances(project, &region)
+	if err != nil {
+		return nil, fmt.Errorf("describing project resources: %w", err)
+	}
+	if len(resources) == 0 {
+		return nil, fmt.Errorf("no regional resources for project %s in region %s found", project.Name, region)
+	}
+
+	return resources[0], nil
+}
+
+// GetRegionalProjectResources fetches all the regional resources for a particular project.
+func (cf CloudFormation) GetRegionalProjectResources(project *archer.Project) ([]*archer.ProjectRegionalResources, error) {
+	resources, err := cf.getResourcesForStackInstances(project, nil)
+	if err != nil {
+		return nil, fmt.Errorf("describing project resources: %w", err)
+	}
+	return resources, nil
+}
+
+func (cf CloudFormation) getResourcesForStackInstances(project *archer.Project, region *string) ([]*archer.ProjectRegionalResources, error) {
+	projectConfig := stack.NewProjectStackConfig(&deploy.CreateProjectInput{
+		Project:   project.Name,
+		AccountID: project.AccountID}, cf.box)
+	listStackInstancesInput := &cloudformation.ListStackInstancesInput{
+		StackSetName:         aws.String(projectConfig.StackSetName()),
+		StackInstanceAccount: aws.String(project.AccountID),
+	}
+
+	if region != nil {
+		listStackInstancesInput.StackInstanceRegion = region
+	}
+
+	stackInstances, err := cf.client.ListStackInstances(listStackInstancesInput)
+
+	if err != nil {
+		return nil, fmt.Errorf("listing stack instances: %w", err)
+	}
+
+	regionalResources := []*archer.ProjectRegionalResources{}
+	for _, stackInstance := range stackInstances.Summaries {
+		// Since these stacks will likely be in another region, we can't use
+		// the default cf client. Instead, we'll have to create a new client
+		// configured with the stack's region.
+		regionAwareCFClient := cf.regionalClientProvider.Client(*stackInstance.Region)
+		cfStack, err := cf.describeStackWithClient(&cloudformation.DescribeStacksInput{
+			StackName: stackInstance.StackId,
+		}, regionAwareCFClient)
+
+		if err != nil {
+			return nil, fmt.Errorf("getting outputs for stack %s in region %s: %w", *stackInstance.StackId, *stackInstance.Region, err)
+		}
+
+		regionalResource, err := stack.ToProjectRegionalResources(cfStack)
+		if err != nil {
+			return nil, err
+		}
+		regionalResource.Region = *stackInstance.Region
+		regionalResources = append(regionalResources, regionalResource)
+	}
+
+	return regionalResources, nil
+}
+
 // AddAppToProject attempts to add new App specific resources to the Project resource stack.
 // Currently, this means that we'll set up an ECR repo with a policy for all envs to be able
 // to pull from it.
diff --git a/internal/pkg/deploy/cloudformation/stack/project.go b/internal/pkg/deploy/cloudformation/stack/project.go
index 0e2a6018..ad1b269c 100644
--- a/internal/pkg/deploy/cloudformation/stack/project.go
+++ b/internal/pkg/deploy/cloudformation/stack/project.go
@@ -11,6 +11,8 @@ import (
 	"strings"
 
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/cloudformation"
 	"gopkg.in/yaml.v3"
@@ -45,6 +47,9 @@ const (
 	projectResourcesTemplatePath  = "project/cf.yml"
 	projectAdminRoleParamName     = "AdminRoleName"
 	projectExecutionRoleParamName = "ExecutionRoleName"
+	projectOutputKMSKey           = "KMSKeyARN"
+	projectOutputS3Bucket         = "PipelineBucket"
+	projectOutputECRRepoPrefix    = "ECRRepo"
 )
 
 // ProjectConfigFrom takes a template file and extracts the metadata block,
@@ -81,11 +86,7 @@ func (c *ProjectStackConfig) ResourceTemplate(config *ProjectResourcesConfig) (s
 	}
 
 	template, err := template.New("resourcetemplate").
-		Funcs(template.FuncMap{
-			"logicalIDSafe": func(logicalID string) string {
-				return strings.ReplaceAll(logicalID, "-", "DASH")
-			},
-		}).
+		Funcs(templateFunctions).
 		Parse(stackSetTemplate)
 	if err != nil {
 		return "", err
@@ -157,3 +158,47 @@ func (c *ProjectStackConfig) StackSetAdminRoleARN() string {
 func (c *ProjectStackConfig) StackSetExecutionRoleName() string {
 	return fmt.Sprintf("%s-executionrole", c.Project)
 }
+
+// ToProjectRegionalResources takes a Project Resource Stack Instance stack, reads the output resources
+// and returns a modeled  ProjectRegionalResources.
+func ToProjectRegionalResources(stack *cloudformation.Stack) (*archer.ProjectRegionalResources, error) {
+	regionalResources := archer.ProjectRegionalResources{
+		RepositoryURLs: map[string]string{},
+	}
+	for _, output := range stack.Outputs {
+		key := *output.OutputKey
+		value := *output.OutputValue
+
+		switch {
+		case key == projectOutputKMSKey:
+			regionalResources.KMSKeyARN = value
+		case key == projectOutputS3Bucket:
+			regionalResources.S3Bucket = value
+		case strings.HasPrefix(key, projectOutputECRRepoPrefix):
+			// If the output starts with the ECR Repo Prefix,
+			// we'll pull the ARN out and construct a URL from it.
+			uri, err := ecr.URIFromARN(value)
+			if err != nil {
+				return nil, err
+			}
+			// The app name for this repo is the Logical ID without
+			// the ECR Repo prefix.
+			safeAppName := strings.TrimPrefix(key, projectOutputECRRepoPrefix)
+			// It's possible we had to sanitize the app name (removing dashes),
+			// so return it back to its original form.
+			originalAppName := safeLogicalIDToOriginal(safeAppName)
+			regionalResources.RepositoryURLs[originalAppName] = uri
+		}
+	}
+	// Check to make sure the KMS key and S3 bucket exist in the stack. There isn't guranteed
+	// to be any ECR repos (for a brand new env without any apps), so we don't validate that.
+	if regionalResources.KMSKeyARN == "" {
+		return nil, fmt.Errorf("couldn't find KMS output key %s in stack %s", projectOutputKMSKey, *stack.StackId)
+	}
+
+	if regionalResources.S3Bucket == "" {
+		return nil, fmt.Errorf("couldn't find S3 bucket output key %s in stack %s", projectOutputS3Bucket, *stack.StackId)
+	}
+
+	return &regionalResources, nil
+}
diff --git a/internal/pkg/deploy/cloudformation/stack/template_functions.go b/internal/pkg/deploy/cloudformation/stack/template_functions.go
new file mode 100644
index 00000000..29045b88
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/stack/template_functions.go
@@ -0,0 +1,28 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package stack
+
+import "strings"
+
+const (
+	dashReplacement = "DASH"
+)
+
+var templateFunctions = map[string]interface{}{
+	"logicalIDSafe": logicalIDSafe,
+}
+
+// logicalIDSafe takes a CloudFormation logical ID, and
+// sanitizes it by removing "-" characters (not allowed)
+// and replacing them with "DASH" (allowed by CloudFormation but
+// not permitted in ecs-cli generated resource names).
+func logicalIDSafe(logicalID string) string {
+	return strings.ReplaceAll(logicalID, "-", dashReplacement)
+}
+
+// safeLogicalIDToOriginal takes a "sanitized" logical ID
+// and converts it back to its original form, with dashes.
+func safeLogicalIDToOriginal(safeLogicalID string) string {
+	return strings.ReplaceAll(safeLogicalID, dashReplacement, "-")
+}


Test patch:
diff --git a/internal/pkg/aws/ecr/erc_test.go b/internal/pkg/aws/ecr/erc_test.go
new file mode 100644
index 00000000..4661cb71
--- /dev/null
+++ b/internal/pkg/aws/ecr/erc_test.go
@@ -0,0 +1,49 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ecr
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestURIFromARN(t *testing.T) {
+
+	testCases := map[string]struct {
+		givenARN  string
+		wantedURI string
+		wantErr   error
+	}{
+		"valid arn": {
+			givenARN:  "arn:aws:ecr:us-west-2:0123456789:repository/myrepo",
+			wantedURI: "0123456789.dkr.ecr.us-west-2.amazonaws.com/myrepo",
+		},
+		"valid arn with namespace": {
+			givenARN:  "arn:aws:ecr:us-west-2:0123456789:repository/myproject/myapp",
+			wantedURI: "0123456789.dkr.ecr.us-west-2.amazonaws.com/myproject/myapp",
+		},
+		"separate region": {
+			givenARN:  "arn:aws:ecr:us-east-1:0123456789:repository/myproject/myapp",
+			wantedURI: "0123456789.dkr.ecr.us-east-1.amazonaws.com/myproject/myapp",
+		},
+		"invalid ARN": {
+			givenARN: "myproject/myapp",
+			wantErr:  fmt.Errorf("parsing repository ARN myproject/myapp: arn: invalid prefix"),
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			uri, err := URIFromARN(tc.givenARN)
+			if tc.wantErr != nil {
+				require.Error(t, err)
+				require.EqualError(t, err, tc.wantErr.Error())
+			} else {
+				require.Equal(t, tc.wantedURI, uri)
+			}
+		})
+	}
+}
diff --git a/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go b/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
index 804bcacd..6322a5f8 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation_integration_test.go
@@ -1,5 +1,4 @@
 // +build integration
-
 // Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 // SPDX-License-Identifier: Apache-2.0
 
@@ -70,6 +69,11 @@ func Test_Project_Infrastructure(t *testing.T) {
 		})
 		require.NoError(t, err)
 
+		// Query using our resources as well:
+		resources, err := deployer.GetRegionalProjectResources(&project)
+		require.NoError(t, err)
+		require.True(t, len(resources) == 0, "No resources for %s should exist.", projectRoleStackName)
+
 		// We should create the project StackSet
 		_, err = cfClient.DescribeStackSet(&awsCF.DescribeStackSetInput{
 			StackSetName: aws.String(projectStackSetName),
@@ -192,6 +196,19 @@ func Test_Project_Infrastructure(t *testing.T) {
 				AccountID: "000312697014",
 			},
 		)
+
+		// Query using our GetRegionalProjectResources function.
+		resources, err := deployer.GetRegionalProjectResources(&project)
+		require.NoError(t, err)
+		require.True(t, len(resources) == 1, "One stack should exist.")
+		stackResources := resources[0]
+		require.True(t, len(stackResources.RepositoryURLs) == 2, "Two repos should exist")
+		require.True(t, stackResources.RepositoryURLs["myapp-frontend"] != "", "Repo URL shouldn't be blank")
+		require.True(t, stackResources.RepositoryURLs["myapp"] != "", "Repo URL shouldn't be blank")
+		require.True(t, stackResources.S3Bucket != "", "S3 Bucket shouldn't be blank")
+		require.True(t, stackResources.KMSKeyARN != "", "KMSKey ARN shouldn't be blank")
+
+		// Validate resources by comparing physical output of the stacks.
 		stackInstances, err = cfClient.ListStackInstances(&awsCF.ListStackInstancesInput{
 			StackSetName: aws.String(projectStackSetName),
 		})
diff --git a/internal/pkg/deploy/cloudformation/cloudformation_test.go b/internal/pkg/deploy/cloudformation/cloudformation_test.go
index c378acfb..90b4211a 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation_test.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation_test.go
@@ -382,7 +382,7 @@ func TestWaitForStackCreation(t *testing.T) {
 		"error if no stacks returned": {
 			cf:    getMockWaitStackCreateCFClient(t, stackConfig.StackName(), false, true),
 			input: stackConfig,
-			want:  fmt.Errorf("failed to find a stack named %s after it was created", stackConfig.StackName()),
+			want:  fmt.Errorf("failed to find a stack named %s", stackConfig.StackName()),
 		},
 		"happy path": {
 			cf:    getMockWaitStackCreateCFClient(t, stackConfig.StackName(), false, false),
diff --git a/internal/pkg/deploy/cloudformation/project_test.go b/internal/pkg/deploy/cloudformation/project_test.go
index a62e780d..4117cd95 100644
--- a/internal/pkg/deploy/cloudformation/project_test.go
+++ b/internal/pkg/deploy/cloudformation/project_test.go
@@ -15,6 +15,7 @@ import (
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/awserr"
 	"github.com/aws/aws-sdk-go/service/cloudformation"
+	"github.com/aws/aws-sdk-go/service/cloudformation/cloudformationiface"
 	"github.com/gobuffalo/packd"
 	"github.com/stretchr/testify/require"
 	"gopkg.in/yaml.v3"
@@ -604,6 +605,203 @@ func TestDeployProjectConfig_ErrWrapping(t *testing.T) {
 	}
 }
 
+func TestGetRegionalProjectResources(t *testing.T) {
+	mockProject := archer.Project{Name: "project", AccountID: "12345"}
+
+	testCases := map[string]struct {
+		cf             CloudFormation
+		clientProvider func(string) cloudformationiface.CloudFormationAPI
+		wantedResource archer.ProjectRegionalResources
+		want           error
+	}{
+		"should describe stack instances and convert to ProjectRegionalResources": {
+			wantedResource: archer.ProjectRegionalResources{
+				KMSKeyARN:      "arn:aws:kms:us-west-2:01234567890:key/0000",
+				S3Bucket:       "tests3-bucket-us-west-2",
+				Region:         "us-east-9",
+				RepositoryURLs: map[string]string{},
+			},
+			clientProvider: func(region string) cloudformationiface.CloudFormationAPI {
+				if region != "us-east-9" {
+					t.FailNow()
+				}
+				return &mockCloudFormation{
+					t: t,
+					mockDescribeStacks: func(t *testing.T, input *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+						require.Equal(t, "cross-region-stack", *input.StackName)
+						return &cloudformation.DescribeStacksOutput{
+								Stacks: []*cloudformation.Stack{mockValidProjectResourceStack()},
+							},
+							nil
+					},
+				}
+			},
+			cf: CloudFormation{
+				client: &mockCloudFormation{
+					t: t,
+					mockListStackInstances: func(t *testing.T, in *cloudformation.ListStackInstancesInput) (*cloudformation.ListStackInstancesOutput, error) {
+						return &cloudformation.ListStackInstancesOutput{
+							Summaries: []*cloudformation.StackInstanceSummary{
+								&cloudformation.StackInstanceSummary{
+									StackId: aws.String("cross-region-stack"),
+									Region:  aws.String("us-east-9"),
+								},
+							},
+						}, nil
+					},
+				},
+				box: boxWithTemplateFile(),
+			},
+		},
+
+		"should propagate describe errors": {
+			want: fmt.Errorf("describing project resources: getting outputs for stack cross-region-stack in region us-east-9: error calling cloudformation"),
+			clientProvider: func(region string) cloudformationiface.CloudFormationAPI {
+				if region != "us-east-9" {
+					t.FailNow()
+				}
+				return &mockCloudFormation{
+					t: t,
+					mockDescribeStacks: func(t *testing.T, input *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+						require.Equal(t, "cross-region-stack", *input.StackName)
+						return nil, fmt.Errorf("error calling cloudformation")
+					},
+				}
+			},
+			cf: CloudFormation{
+				client: &mockCloudFormation{
+					t: t,
+					mockListStackInstances: func(t *testing.T, in *cloudformation.ListStackInstancesInput) (*cloudformation.ListStackInstancesOutput, error) {
+						return &cloudformation.ListStackInstancesOutput{
+							Summaries: []*cloudformation.StackInstanceSummary{
+								&cloudformation.StackInstanceSummary{
+									StackId: aws.String("cross-region-stack"),
+									Region:  aws.String("us-east-9"),
+								},
+							},
+						}, nil
+					},
+				},
+				box: boxWithTemplateFile(),
+			},
+		},
+
+		"should propagate list stack instances errors": {
+			want: fmt.Errorf("describing project resources: listing stack instances: error"),
+			cf: CloudFormation{
+				client: &mockCloudFormation{
+					t: t,
+					mockListStackInstances: func(t *testing.T, in *cloudformation.ListStackInstancesInput) (*cloudformation.ListStackInstancesOutput, error) {
+						return nil, fmt.Errorf("error")
+					},
+				},
+				box: boxWithTemplateFile(),
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.cf.regionalClientProvider = mockClientBuilder{
+				mockClient: tc.clientProvider,
+			}
+			got, err := tc.cf.GetRegionalProjectResources(&mockProject)
+			if tc.want != nil {
+				require.Error(t, err)
+				require.EqualError(t, err, tc.want.Error())
+			} else {
+				require.True(t, len(got) == 1, "Expected only one resource")
+				// Assert that the project resources are the same.
+				require.Equal(t, tc.wantedResource, *got[0])
+			}
+		})
+	}
+}
+
+func TestGetProjectResourcesByRegion(t *testing.T) {
+	mockProject := archer.Project{Name: "project", AccountID: "12345"}
+
+	testCases := map[string]struct {
+		cf             CloudFormation
+		clientProvider func(string) cloudformationiface.CloudFormationAPI
+		wantedResource archer.ProjectRegionalResources
+		region         string
+		want           error
+	}{
+		"should describe stack instances and convert to ProjectRegionalResources": {
+			wantedResource: archer.ProjectRegionalResources{
+				KMSKeyARN:      "arn:aws:kms:us-west-2:01234567890:key/0000",
+				S3Bucket:       "tests3-bucket-us-west-2",
+				Region:         "us-east-9",
+				RepositoryURLs: map[string]string{},
+			},
+			region: "us-east-9",
+			clientProvider: func(region string) cloudformationiface.CloudFormationAPI {
+				if region != "us-east-9" {
+					t.FailNow()
+				}
+				return &mockCloudFormation{
+					t: t,
+					mockDescribeStacks: func(t *testing.T, input *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+						require.Equal(t, "cross-region-stack", *input.StackName)
+						return &cloudformation.DescribeStacksOutput{
+								Stacks: []*cloudformation.Stack{mockValidProjectResourceStack()},
+							},
+							nil
+					},
+				}
+			},
+			cf: CloudFormation{
+				client: &mockCloudFormation{
+					t: t,
+					mockListStackInstances: func(t *testing.T, in *cloudformation.ListStackInstancesInput) (*cloudformation.ListStackInstancesOutput, error) {
+						require.Equal(t, "us-east-9", *in.StackInstanceRegion)
+						return &cloudformation.ListStackInstancesOutput{
+							Summaries: []*cloudformation.StackInstanceSummary{
+								&cloudformation.StackInstanceSummary{
+									StackId: aws.String("cross-region-stack"),
+									Region:  aws.String("us-east-9"),
+								},
+							},
+						}, nil
+					},
+				},
+				box: boxWithTemplateFile(),
+			},
+		},
+		"should error when resources are found": {
+			want:   fmt.Errorf("no regional resources for project project in region us-east-9 found"),
+			region: "us-east-9",
+			cf: CloudFormation{
+				client: &mockCloudFormation{
+					t: t,
+					mockListStackInstances: func(t *testing.T, in *cloudformation.ListStackInstancesInput) (*cloudformation.ListStackInstancesOutput, error) {
+						return &cloudformation.ListStackInstancesOutput{}, nil
+					},
+				},
+				box: boxWithTemplateFile(),
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.cf.regionalClientProvider = mockClientBuilder{
+				mockClient: tc.clientProvider,
+			}
+			got, err := tc.cf.GetProjectResourcesByRegion(&mockProject, tc.region)
+			if tc.want != nil {
+				require.Error(t, err)
+				require.EqualError(t, err, tc.want.Error())
+			} else {
+				require.NotNil(t, got)
+				// Assert that the project resources are the same.
+				require.Equal(t, tc.wantedResource, *got)
+			}
+		})
+	}
+}
+
 // Useful for mocking a successfully deployed stack
 func getMockSuccessfulDeployCFClient(t *testing.T, stackName string) *mockCloudFormation {
 	return &mockCloudFormation{
@@ -641,6 +839,36 @@ func getMockSuccessfulDeployCFClient(t *testing.T, stackName string) *mockCloudF
 	}
 }
 
+type mockClientBuilder struct {
+	mockClient func(string) cloudformationiface.CloudFormationAPI
+}
+
+func (cf mockClientBuilder) Client(region string) cloudformationiface.CloudFormationAPI {
+	return cf.mockClient(region)
+}
+
+func mockValidProjectResourceStack() *cloudformation.Stack {
+	return mockProjectResourceStack("stack", map[string]string{
+		"KMSKeyARN":      "arn:aws:kms:us-west-2:01234567890:key/0000",
+		"PipelineBucket": "tests3-bucket-us-west-2",
+	})
+}
+
+func mockProjectResourceStack(stackArn string, outputs map[string]string) *cloudformation.Stack {
+	outputList := []*cloudformation.Output{}
+	for key, val := range outputs {
+		outputList = append(outputList, &cloudformation.Output{
+			OutputKey:   aws.String(key),
+			OutputValue: aws.String(val),
+		})
+	}
+
+	return &cloudformation.Stack{
+		StackId: aws.String(stackArn),
+		Outputs: outputList,
+	}
+}
+
 func boxWithProjectTemplate() packd.Box {
 	box := packd.NewMemoryBox()
 
diff --git a/internal/pkg/deploy/cloudformation/stack/project_test.go b/internal/pkg/deploy/cloudformation/stack/project_test.go
index d5e6881b..337e1aba 100644
--- a/internal/pkg/deploy/cloudformation/stack/project_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/project_test.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"testing"
 
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy"
 	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/cloudformation"
@@ -131,6 +132,75 @@ func TestProjectTags(t *testing.T) {
 	require.ElementsMatch(t, expectedTags, proj.Tags())
 }
 
+func TestToRegionalResourcess(t *testing.T) {
+	testCases := map[string]struct {
+		givenStackOutputs map[string]string
+		wantedResource    archer.ProjectRegionalResources
+		wantedErr         error
+	}{
+		"should generate fully formed resource": {
+			givenStackOutputs: map[string]string{
+				projectOutputKMSKey:   "arn:aws:kms:us-west-2:01234567890:key/0000",
+				projectOutputS3Bucket: "tests3-bucket-us-west-2",
+				"ECRRepofrontDASHend": "arn:aws:ecr:us-west-2:0123456789:repository/project/front-end",
+				"ECRRepobackDASHend":  "arn:aws:ecr:us-west-2:0123456789:repository/project/back-end",
+			},
+			wantedResource: archer.ProjectRegionalResources{
+				KMSKeyARN: "arn:aws:kms:us-west-2:01234567890:key/0000",
+				S3Bucket:  "tests3-bucket-us-west-2",
+				RepositoryURLs: map[string]string{
+					"front-end": "0123456789.dkr.ecr.us-west-2.amazonaws.com/project/front-end",
+					"back-end":  "0123456789.dkr.ecr.us-west-2.amazonaws.com/project/back-end",
+				},
+			},
+		},
+		"should return error when no bucket exists": {
+			givenStackOutputs: map[string]string{
+				projectOutputKMSKey:   "arn:aws:kms:us-west-2:01234567890:key/0000",
+				"ECRRepofrontDASHend": "arn:aws:ecr:us-west-2:0123456789:repository/project/front-end",
+				"ECRRepobackDASHend":  "arn:aws:ecr:us-west-2:0123456789:repository/project/back-end",
+			},
+			wantedErr: fmt.Errorf("couldn't find S3 bucket output key PipelineBucket in stack stack"),
+		},
+		"should return error when no kms key exists": {
+			givenStackOutputs: map[string]string{
+				projectOutputS3Bucket: "tests3-bucket-us-west-2",
+				"ECRRepofrontDASHend": "arn:aws:ecr:us-west-2:0123456789:repository/project/front-end",
+				"ECRRepobackDASHend":  "arn:aws:ecr:us-west-2:0123456789:repository/project/back-end",
+			},
+			wantedErr: fmt.Errorf("couldn't find KMS output key KMSKeyARN in stack stack"),
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			got, err := ToProjectRegionalResources(mockProjectResourceStack("stack", tc.givenStackOutputs))
+
+			if tc.wantedErr != nil {
+				require.EqualError(t, tc.wantedErr, err.Error())
+			} else {
+				require.NoError(t, err)
+				require.Equal(t, tc.wantedResource, *got)
+			}
+		})
+	}
+}
+
+func mockProjectResourceStack(stackArn string, outputs map[string]string) *cloudformation.Stack {
+	outputList := []*cloudformation.Output{}
+	for key, val := range outputs {
+		outputList = append(outputList, &cloudformation.Output{
+			OutputKey:   aws.String(key),
+			OutputValue: aws.String(val),
+		})
+	}
+
+	return &cloudformation.Stack{
+		StackId: aws.String(stackArn),
+		Outputs: outputList,
+	}
+}
+
 func TestProjectStackName(t *testing.T) {
 	proj := NewProjectStackConfig(&deploy.CreateProjectInput{Project: "testproject", AccountID: "1234"}, emptyProjectBox())
 	require.Equal(t, fmt.Sprintf("%s-infrastructure-roles", proj.Project), proj.StackName())