Repository: aws/amazon-ecs-cli-v2 (go)

Issue:
Need a URL when I finish deploying
I go through archer init successfully, but then I don't know where my endpoint is.  It just says "Deployed trivia-api:e5f728b to test."  I'm not sure how to get the URL so I can go play with my application (i.e. http://clare-Publi-534J9K0KNAPP-779253318.us-west-2.elb.amazonaws.com)

```
$ archer init
Note: It's best to run this command in the root of your workspace.
Welcome to the ECS CLI! We're going to walk you through some questions 
to help you get set up with a project on ECS. A project is a collection of 
containerized applications (or micro-services) that operate together.

Looks like you don't have any existing projects. Let's create one!
? What would you like to call your project? [? for help] 
Error: prompt for project init: prompt get project name: interrupt
liguori:~/code/demos/trivia-api$ archer init
Note: It's best to run this command in the root of your workspace.
Welcome to the ECS CLI! We're going to walk you through some questions 
to help you get set up with a project on ECS. A project is a collection of 
containerized applications (or micro-services) that operate together.

Looks like you don't have any existing projects. Let's create one!
? What would you like to call your project? clares-demos
? Which type of infrastructure pattern best represents your application? Load Balanced Web App
? What do you want to call this Load Balanced Web App? trivia-api
? Which Dockerfile would you like to use for trivia-api app? ./Dockerfile
Ok great, we'll set up a Load Balanced Web App named trivia-api in project clares-demos.
✔ Created the infrastructure to manage container repositories under project clares-demos.

✔ Wrote the manifest for trivia-api app at 'ecs-project/trivia-api-app.yml'
Your manifest contains configurations like your container size and ports.

✔ Created ECR repositories for application trivia-api.
All right, you're all set for local development.
? Would you like to deploy a staging environment? Yes
✔ Created the infrastructure for the test environment.
- Virtual private cloud on 2 availability zones to hold your services     [Complete]
  - Internet gateway to connect the network to the internet               [Complete]
  - Public subnets for internet facing services                           [Complete]
  - Private subnets for services that can't be reached from the internet  [Complete]
  - Routing tables for services to talk with each other                   [Complete]
- ECS Cluster to hold your services                                       [Complete]
- Application load balancer to distribute traffic                         [Complete]
✔ Linked account 131296546870 and region us-west-2 project clares-demos.
✔ Created environment test in region us-west-2 under project clares-demos.
<snip>
sha256:807de4fe1eff1a1b26c5d6153b45fb4047cf05428efe8a7a61a327ff70430fdd size: 2416
Done!
✔ Deployed trivia-api:e5f728b to test.
```

Pull request description:
Display the URI for an application after deployment
Resolves #471 

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/Makefile b/Makefile
index 1ef4b511..6c8e7c90 100644
--- a/Makefile
+++ b/Makefile
@@ -137,3 +137,4 @@ gen-mocks: tools
 	${GOBIN}/mockgen -source=./internal/pkg/cli/deploy.go -package=mocks -destination=./internal/pkg/cli/mocks/mock_deploy.go
 	${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/cli/mocks/mock_rg.go github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface ResourceGroupsTaggingAPIAPI
 	${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/cli/mocks/mock_iam.go github.com/aws/aws-sdk-go/service/iam/iamiface IAMAPI
+	${GOBIN}/mockgen -source=./internal/pkg/describe/describe.go -package=mocks -destination=./internal/pkg/describe/mocks/mock_describe.go
diff --git a/internal/pkg/cli/app_deploy.go b/internal/pkg/cli/app_deploy.go
index 03ad587e..b5109115 100644
--- a/internal/pkg/cli/app_deploy.go
+++ b/internal/pkg/cli/app_deploy.go
@@ -17,6 +17,7 @@ import (
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/docker"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation/stack"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/describe"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/store"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color"
@@ -359,7 +360,7 @@ func (opts appDeployOpts) deployApp() error {
 	if err != nil {
 		return fmt.Errorf("failed to generate random id for changeSet: %w", err)
 	}
-	stackName := fmt.Sprintf("%s-%s-%s", opts.ProjectName(), opts.targetEnvironment.Name, opts.app)
+	stackName := stack.NameForApp(opts.ProjectName(), opts.targetEnvironment.Name, opts.app)
 	changeSetName := fmt.Sprintf("%s-%s", stackName, id)
 
 	opts.spinner.Start(
@@ -377,11 +378,17 @@ func (opts appDeployOpts) deployApp() error {
 		opts.spinner.Stop("Error!")
 		return err
 	}
-	opts.spinner.Stop("Done!")
+	opts.spinner.Stop("")
 
-	log.Successf("Deployed %s to %s.\n",
-		fmt.Sprintf("%s:%s", color.HighlightUserInput(opts.app), color.HighlightUserInput(opts.imageTag)),
-		color.HighlightUserInput(opts.targetEnvironment.Name))
+	identifier, err := describe.NewAppIdentifier(opts.ProjectName(), opts.app)
+	if err != nil {
+		return fmt.Errorf("create identifier for application %s in project %s: %w", opts.app, opts.ProjectName(), err)
+	}
+	uri, err = identifier.URI(opts.targetEnvironment.Name)
+	if err != nil {
+		return fmt.Errorf("cannot retrieve the URI from environment %s: %w", opts.env, err)
+	}
+	log.Successf("Deployed %s, you can access it at %s\n", color.HighlightUserInput(opts.app), uri)
 
 	return nil
 }
diff --git a/internal/pkg/deploy/cloudformation/stack/env.go b/internal/pkg/deploy/cloudformation/stack/env.go
index e001d96e..8c5f772f 100644
--- a/internal/pkg/deploy/cloudformation/stack/env.go
+++ b/internal/pkg/deploy/cloudformation/stack/env.go
@@ -41,8 +41,10 @@ const (
 
 // Output keys.
 const (
-	envOutputCFNExecutionRoleARN = "CFNExecutionRoleARN"
-	envOutputManagerRoleKey      = "EnvironmentManagerRoleARN"
+	EnvOutputCFNExecutionRoleARN       = "CFNExecutionRoleARN"
+	EnvOutputManagerRoleKey            = "EnvironmentManagerRoleARN"
+	EnvOutputPublicLoadBalancerDNSName = "PublicLoadBalancerDNSName"
+	EnvOutputSubdomain                 = "EnvironmentSubdomain"
 )
 
 // NewEnvStackConfig sets up a struct which can provide values to CloudFormation for
@@ -151,7 +153,7 @@ func (e *EnvStackConfig) dnsDelegationRole() string {
 
 // StackName returns the name of the CloudFormation stack (based on the project and env names).
 func (e *EnvStackConfig) StackName() string {
-	return fmt.Sprintf("%s-%s", e.Project, e.Name)
+	return NameForEnv(e.Project, e.Name)
 }
 
 // ToEnv inspects an environment cloudformation stack and constructs an environment
@@ -173,7 +175,7 @@ func (e *EnvStackConfig) ToEnv(stack *cloudformation.Stack) (*archer.Environment
 		Prod:             e.Prod,
 		Region:           stackARN.Region,
 		AccountID:        stackARN.AccountID,
-		ManagerRoleARN:   stackOutputs[envOutputManagerRoleKey],
-		ExecutionRoleARN: stackOutputs[envOutputCFNExecutionRoleARN],
+		ManagerRoleARN:   stackOutputs[EnvOutputManagerRoleKey],
+		ExecutionRoleARN: stackOutputs[EnvOutputCFNExecutionRoleARN],
 	}, nil
 }
diff --git a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
index b9513ab0..1fe33db3 100644
--- a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
+++ b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
@@ -23,17 +23,18 @@ const (
 	lbFargateAppRulePriorityGeneratorPath = "custom-resources/alb-rule-priority-generator.js"
 )
 
+// Parameter logical IDs for a load balanced Fargate service.
 const (
-	lbFargateParamProjectNameKey    = "ProjectName"
-	lbFargatePramHTTPSKey           = "HTTPSEnabled"
-	lbFargateParamEnvNameKey        = "EnvName"
-	lbFargateParamAppNameKey        = "AppName"
-	lbFargateParamContainerImageKey = "ContainerImage"
-	lbFargateParamContainerPortKey  = "ContainerPort"
-	lbFargateRulePathKey            = "RulePath"
-	lbFargateTaskCPUKey             = "TaskCPU"
-	lbFargateTaskMemoryKey          = "TaskMemory"
-	lbFargateTaskCountKey           = "TaskCount"
+	LBFargateParamProjectNameKey    = "ProjectName"
+	LBFargateParamHTTPSKey          = "HTTPSEnabled"
+	LBFargateParamEnvNameKey        = "EnvName"
+	LBFargateParamAppNameKey        = "AppName"
+	LBFargateParamContainerImageKey = "ContainerImage"
+	LBFargateParamContainerPortKey  = "ContainerPort"
+	LBFargateRulePathKey            = "RulePath"
+	LBFargateTaskCPUKey             = "TaskCPU"
+	LBFargateTaskMemoryKey          = "TaskMemory"
+	LBFargateTaskCountKey           = "TaskCount"
 )
 
 // LBFargateStackConfig represents the configuration needed to create a CloudFormation stack from a
@@ -66,13 +67,7 @@ func NewHTTPSLBFargateStack(in *deploy.CreateLBFargateAppInput) *LBFargateStackC
 
 // StackName returns the name of the stack.
 func (c *LBFargateStackConfig) StackName() string {
-	const maxLen = 128 // stack name limit constrained by CFN https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-using-console-create-stack-parameters.html
-	stackName := fmt.Sprintf("%s-%s-%s-app", c.Env.Project, c.Env.Name, c.App.Name)
-
-	if len(stackName) > maxLen {
-		return stackName[len(stackName)-maxLen:]
-	}
-	return stackName
+	return NameForApp(c.Env.Project, c.Env.Name, c.App.Name)
 }
 
 // Template returns the CloudFormation template for the application parametrized for the environment.
@@ -113,43 +108,43 @@ func (c *LBFargateStackConfig) Parameters() []*cloudformation.Parameter {
 	templateParams := c.toTemplateParams()
 	return []*cloudformation.Parameter{
 		{
-			ParameterKey:   aws.String(lbFargateParamProjectNameKey),
+			ParameterKey:   aws.String(LBFargateParamProjectNameKey),
 			ParameterValue: aws.String(templateParams.Env.Project),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateParamEnvNameKey),
+			ParameterKey:   aws.String(LBFargateParamEnvNameKey),
 			ParameterValue: aws.String(templateParams.Env.Name),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateParamAppNameKey),
+			ParameterKey:   aws.String(LBFargateParamAppNameKey),
 			ParameterValue: aws.String(templateParams.App.Name),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateParamContainerImageKey),
+			ParameterKey:   aws.String(LBFargateParamContainerImageKey),
 			ParameterValue: aws.String(templateParams.Image.URL),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateParamContainerPortKey),
+			ParameterKey:   aws.String(LBFargateParamContainerPortKey),
 			ParameterValue: aws.String(strconv.Itoa(templateParams.Image.Port)),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateRulePathKey),
+			ParameterKey:   aws.String(LBFargateRulePathKey),
 			ParameterValue: aws.String(templateParams.App.Path),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateTaskCPUKey),
+			ParameterKey:   aws.String(LBFargateTaskCPUKey),
 			ParameterValue: aws.String(strconv.Itoa(templateParams.App.CPU)),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateTaskMemoryKey),
+			ParameterKey:   aws.String(LBFargateTaskMemoryKey),
 			ParameterValue: aws.String(strconv.Itoa(templateParams.App.Memory)),
 		},
 		{
-			ParameterKey:   aws.String(lbFargateTaskCountKey),
+			ParameterKey:   aws.String(LBFargateTaskCountKey),
 			ParameterValue: aws.String(strconv.Itoa(templateParams.App.Count)),
 		},
 		{
-			ParameterKey:   aws.String(lbFargatePramHTTPSKey),
+			ParameterKey:   aws.String(LBFargateParamHTTPSKey),
 			ParameterValue: aws.String(strconv.FormatBool(c.httpsEnabled)),
 		},
 	}
diff --git a/internal/pkg/deploy/cloudformation/stack/name.go b/internal/pkg/deploy/cloudformation/stack/name.go
new file mode 100644
index 00000000..0ab01572
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/stack/name.go
@@ -0,0 +1,23 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package stack
+
+import "fmt"
+
+// NameForApp returns the stack name for an application.
+func NameForApp(project, env, app string) string {
+	// stack name limit constrained by CFN https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-using-console-create-stack-parameters.html
+	const maxLen = 128
+	stackName := fmt.Sprintf("%s-%s-%s", project, env, app)
+
+	if len(stackName) > maxLen {
+		return stackName[:maxLen]
+	}
+	return stackName
+}
+
+// NameForEnv returns the stack name for an environment.
+func NameForEnv(project, env string) string {
+	return fmt.Sprintf("%s-%s", project, env)
+}
diff --git a/internal/pkg/describe/describe.go b/internal/pkg/describe/describe.go
new file mode 100644
index 00000000..d63b3bf4
--- /dev/null
+++ b/internal/pkg/describe/describe.go
@@ -0,0 +1,40 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package describe provides information on deployed resources.
+package describe
+
+import (
+	"fmt"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/store"
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+)
+
+// ResourceIdentifier is the interface to uniquely identify a resource created with the ECS CLI.
+type ResourceIdentifier interface {
+	URI(envName string) (string, error)
+}
+
+// NewAppIdentifier instantiates an application with a ResourceIdentifier.
+func NewAppIdentifier(project, app string) (ResourceIdentifier, error) {
+	svc, err := store.New()
+	if err != nil {
+		return nil, fmt.Errorf("connect to store: %w", err)
+	}
+	meta, err := svc.GetApplication(project, app)
+	if err != nil {
+		return nil, err
+	}
+	switch t := meta.Type; t {
+	case manifest.LoadBalancedWebApplication:
+		return newWebAppDescriber(meta, svc), nil
+	default:
+		return nil, fmt.Errorf("application type %s cannot be identified", t)
+	}
+}
+
+type stackDescriber interface {
+	DescribeStacks(input *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error)
+}
diff --git a/internal/pkg/describe/mocks/mock_describe.go b/internal/pkg/describe/mocks/mock_describe.go
new file mode 100644
index 00000000..3bcbf03b
--- /dev/null
+++ b/internal/pkg/describe/mocks/mock_describe.go
@@ -0,0 +1,103 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./internal/pkg/describe/describe.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	archer "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	cloudformation "github.com/aws/aws-sdk-go/service/cloudformation"
+	gomock "github.com/golang/mock/gomock"
+	reflect "reflect"
+)
+
+// MockenvAppGetter is a mock of envAppGetter interface
+type MockenvAppGetter struct {
+	ctrl     *gomock.Controller
+	recorder *MockenvAppGetterMockRecorder
+}
+
+// MockenvAppGetterMockRecorder is the mock recorder for MockenvAppGetter
+type MockenvAppGetterMockRecorder struct {
+	mock *MockenvAppGetter
+}
+
+// NewMockenvAppGetter creates a new mock instance
+func NewMockenvAppGetter(ctrl *gomock.Controller) *MockenvAppGetter {
+	mock := &MockenvAppGetter{ctrl: ctrl}
+	mock.recorder = &MockenvAppGetterMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockenvAppGetter) EXPECT() *MockenvAppGetterMockRecorder {
+	return m.recorder
+}
+
+// GetEnvironment mocks base method
+func (m *MockenvAppGetter) GetEnvironment(projectName, environmentName string) (*archer.Environment, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetEnvironment", projectName, environmentName)
+	ret0, _ := ret[0].(*archer.Environment)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetEnvironment indicates an expected call of GetEnvironment
+func (mr *MockenvAppGetterMockRecorder) GetEnvironment(projectName, environmentName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnvironment", reflect.TypeOf((*MockenvAppGetter)(nil).GetEnvironment), projectName, environmentName)
+}
+
+// GetApplication mocks base method
+func (m *MockenvAppGetter) GetApplication(projectName, applicationName string) (*archer.Application, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetApplication", projectName, applicationName)
+	ret0, _ := ret[0].(*archer.Application)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetApplication indicates an expected call of GetApplication
+func (mr *MockenvAppGetterMockRecorder) GetApplication(projectName, applicationName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetApplication", reflect.TypeOf((*MockenvAppGetter)(nil).GetApplication), projectName, applicationName)
+}
+
+// MockstackDescriber is a mock of stackDescriber interface
+type MockstackDescriber struct {
+	ctrl     *gomock.Controller
+	recorder *MockstackDescriberMockRecorder
+}
+
+// MockstackDescriberMockRecorder is the mock recorder for MockstackDescriber
+type MockstackDescriberMockRecorder struct {
+	mock *MockstackDescriber
+}
+
+// NewMockstackDescriber creates a new mock instance
+func NewMockstackDescriber(ctrl *gomock.Controller) *MockstackDescriber {
+	mock := &MockstackDescriber{ctrl: ctrl}
+	mock.recorder = &MockstackDescriberMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockstackDescriber) EXPECT() *MockstackDescriberMockRecorder {
+	return m.recorder
+}
+
+// DescribeStacks mocks base method
+func (m *MockstackDescriber) DescribeStacks(input *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DescribeStacks", input)
+	ret0, _ := ret[0].(*cloudformation.DescribeStacksOutput)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// DescribeStacks indicates an expected call of DescribeStacks
+func (mr *MockstackDescriberMockRecorder) DescribeStacks(input interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeStacks", reflect.TypeOf((*MockstackDescriber)(nil).DescribeStacks), input)
+}
diff --git a/internal/pkg/describe/webapp.go b/internal/pkg/describe/webapp.go
new file mode 100644
index 00000000..c4c7dfdd
--- /dev/null
+++ b/internal/pkg/describe/webapp.go
@@ -0,0 +1,126 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package describe
+
+import (
+	"fmt"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/session"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation/stack"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color"
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+)
+
+// webAppURI represents the unique identifier to access a web application.
+type webAppURI struct {
+	DNSName string // The environment's subdomain if the application is served on HTTPS. Otherwise, the public load balancer's DNS.
+	Path    string // Empty if the application is served on HTTPS. Otherwise, the pattern used to match the application.
+}
+
+func (uri *webAppURI) String() string {
+	if uri.Path != "" {
+		return fmt.Sprintf("%s and path %s", color.HighlightResource("http://"+uri.DNSName), color.HighlightResource(uri.Path))
+	}
+	return color.HighlightResource("https://" + uri.DNSName)
+}
+
+// webAppDescriber retrieves information about a load balanced web application.
+type webAppDescriber struct {
+	app *archer.Application
+
+	store           archer.EnvironmentGetter
+	stackDescribers map[string]stackDescriber
+}
+
+func newWebAppDescriber(app *archer.Application, store archer.EnvironmentGetter) *webAppDescriber {
+	return &webAppDescriber{
+		app:             app,
+		store:           store,
+		stackDescribers: make(map[string]stackDescriber),
+	}
+}
+
+// URI returns the stringified webAppURI to identify this application uniquely given an environment name.
+func (d *webAppDescriber) URI(envName string) (string, error) {
+	env, err := d.store.GetEnvironment(d.app.Project, envName)
+	if err != nil {
+		return "", err
+	}
+
+	envOutputs, err := d.envOutputs(env)
+	if err != nil {
+		return "", err
+	}
+	appParams, err := d.appParams(env)
+	if err != nil {
+		return "", err
+	}
+
+	uri := &webAppURI{
+		DNSName: envOutputs[stack.EnvOutputPublicLoadBalancerDNSName],
+		Path:    appParams[stack.LBFargateRulePathKey],
+	}
+	_, isHTTPS := envOutputs[stack.EnvOutputSubdomain]
+	if isHTTPS {
+		dnsName := fmt.Sprintf("%s.%s", d.app.Name, envOutputs[stack.EnvOutputSubdomain])
+		uri = &webAppURI{
+			DNSName: dnsName,
+		}
+	}
+	return uri.String(), nil
+}
+
+func (d *webAppDescriber) envOutputs(env *archer.Environment) (map[string]string, error) {
+	envStack, err := d.stack(env.ManagerRoleARN, env.Region, stack.NameForEnv(d.app.Project, env.Name))
+	if err != nil {
+		return nil, err
+	}
+	outputs := make(map[string]string)
+	for _, out := range envStack.Outputs {
+		outputs[*out.OutputKey] = *out.OutputValue
+	}
+	return outputs, nil
+}
+
+func (d *webAppDescriber) appParams(env *archer.Environment) (map[string]string, error) {
+	appStack, err := d.stack(env.ManagerRoleARN, env.Region, stack.NameForApp(d.app.Project, env.Name, d.app.Name))
+	if err != nil {
+		return nil, err
+	}
+	params := make(map[string]string)
+	for _, param := range appStack.Parameters {
+		params[*param.ParameterKey] = *param.ParameterValue
+	}
+	return params, nil
+}
+
+func (d *webAppDescriber) stack(roleARN, region, stackName string) (*cloudformation.Stack, error) {
+	svc, err := d.stackDescriber(roleARN, region)
+	if err != nil {
+		return nil, err
+	}
+	out, err := svc.DescribeStacks(&cloudformation.DescribeStacksInput{
+		StackName: aws.String(stackName),
+	})
+	if err != nil {
+		return nil, fmt.Errorf("describe stack %s: %w", stackName, err)
+	}
+	if len(out.Stacks) == 0 {
+		return nil, fmt.Errorf("stack %s not found", stackName)
+	}
+	return out.Stacks[0], nil
+}
+
+func (d *webAppDescriber) stackDescriber(roleARN, region string) (stackDescriber, error) {
+	if _, ok := d.stackDescribers[roleARN]; !ok {
+		sess, err := session.FromRole(roleARN, region)
+		if err != nil {
+			return nil, fmt.Errorf("session for role %s and region %s: %w", roleARN, region, err)
+		}
+		d.stackDescribers[roleARN] = cloudformation.New(sess)
+	}
+	return d.stackDescribers[roleARN], nil
+}


Test patch:
diff --git a/internal/pkg/deploy/cloudformation/stack/env_test.go b/internal/pkg/deploy/cloudformation/stack/env_test.go
index 933052c6..b7480947 100644
--- a/internal/pkg/deploy/cloudformation/stack/env_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/env_test.go
@@ -243,11 +243,11 @@ func mockEnvironmentStack(stackArn, managerRoleARN, executionRoleARN string) *cl
 		StackId: aws.String(stackArn),
 		Outputs: []*cloudformation.Output{
 			{
-				OutputKey:   aws.String(envOutputManagerRoleKey),
+				OutputKey:   aws.String(EnvOutputManagerRoleKey),
 				OutputValue: aws.String(managerRoleARN),
 			},
 			{
-				OutputKey:   aws.String(envOutputCFNExecutionRoleARN),
+				OutputKey:   aws.String(EnvOutputCFNExecutionRoleARN),
 				OutputValue: aws.String(executionRoleARN),
 			},
 		},
diff --git a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go
index 13de369f..462308c0 100644
--- a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go
@@ -30,14 +30,14 @@ func TestLBFargateStackConfig_StackName(t *testing.T) {
 			inEnvName:     "test",
 			inProjectName: "phonetool",
 
-			wantedStackName: "phonetool-test-frontend-app",
+			wantedStackName: "phonetool-test-frontend",
 		},
 		"longer than 128 characters": {
 			inAppName:     "whatisthishorriblylongapplicationnamethatcantfitintocloudformationwhatarewesupposedtodoaboutthisaaaaaaaaaaaaaaaaaaaa",
 			inEnvName:     "test",
 			inProjectName: "phonetool",
 
-			wantedStackName: "ol-test-whatisthishorriblylongapplicationnamethatcantfitintocloudformationwhatarewesupposedtodoaboutthisaaaaaaaaaaaaaaaaaaaa-app",
+			wantedStackName: "phonetool-test-whatisthishorriblylongapplicationnamethatcantfitintocloudformationwhatarewesupposedtodoaboutthisaaaaaaaaaaaaaaaaa",
 		},
 	}
 
@@ -196,43 +196,43 @@ func TestLBFargateStackConfig_Parameters(t *testing.T) {
 			// THEN
 			require.Equal(t, []*cloudformation.Parameter{
 				{
-					ParameterKey:   aws.String(lbFargateParamProjectNameKey),
+					ParameterKey:   aws.String(LBFargateParamProjectNameKey),
 					ParameterValue: aws.String("phonetool"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateParamEnvNameKey),
+					ParameterKey:   aws.String(LBFargateParamEnvNameKey),
 					ParameterValue: aws.String("test"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateParamAppNameKey),
+					ParameterKey:   aws.String(LBFargateParamAppNameKey),
 					ParameterValue: aws.String("frontend"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateParamContainerImageKey),
+					ParameterKey:   aws.String(LBFargateParamContainerImageKey),
 					ParameterValue: aws.String("12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/frontend:manual-bf3678c"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateParamContainerPortKey),
+					ParameterKey:   aws.String(LBFargateParamContainerPortKey),
 					ParameterValue: aws.String("80"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateRulePathKey),
+					ParameterKey:   aws.String(LBFargateRulePathKey),
 					ParameterValue: aws.String("*"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateTaskCPUKey),
+					ParameterKey:   aws.String(LBFargateTaskCPUKey),
 					ParameterValue: aws.String("256"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateTaskMemoryKey),
+					ParameterKey:   aws.String(LBFargateTaskMemoryKey),
 					ParameterValue: aws.String("512"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargateTaskCountKey),
+					ParameterKey:   aws.String(LBFargateTaskCountKey),
 					ParameterValue: aws.String("1"),
 				},
 				{
-					ParameterKey:   aws.String(lbFargatePramHTTPSKey),
+					ParameterKey:   aws.String(LBFargateParamHTTPSKey),
 					ParameterValue: aws.String(tc.expectedHTTP),
 				},
 			}, params)
diff --git a/internal/pkg/describe/webapp_test.go b/internal/pkg/describe/webapp_test.go
new file mode 100644
index 00000000..a5a5e1a9
--- /dev/null
+++ b/internal/pkg/describe/webapp_test.go
@@ -0,0 +1,253 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package describe
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation/stack"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/describe/mocks"
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWebAppURI_String(t *testing.T) {
+	testCases := map[string]struct {
+		dnsName string
+		path    string
+
+		wanted string
+	}{
+		"http": {
+			dnsName: "abc.us-west-1.elb.amazonaws.com",
+			path:    "*",
+
+			wanted: "http://abc.us-west-1.elb.amazonaws.com and path *",
+		},
+		"https": {
+			dnsName: "jobs.test.phonetool.com",
+			path:    "",
+
+			wanted: "https://jobs.test.phonetool.com",
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			uri := &webAppURI{
+				DNSName: tc.dnsName,
+				Path:    tc.path,
+			}
+
+			require.Equal(t, tc.wanted, uri.String())
+		})
+	}
+}
+
+func TestWebAppDescriber_URI(t *testing.T) {
+	const (
+		testProject        = "phonetool"
+		testEnv            = "test"
+		testManagerRoleARN = "arn:aws:iam::1111:role/manager"
+		testApp            = "jobs"
+		testEnvSubdomain   = "test.phonetool.com"
+		testEnvLBDNSName   = "http://abc.us-west-1.elb.amazonaws.com"
+		testAppPath        = "*"
+	)
+	testCases := map[string]struct {
+		mockStore           func(ctrl *gomock.Controller) *mocks.MockenvAppGetter
+		mockStackDescribers func(ctrl *gomock.Controller) map[string]stackDescriber
+
+		wantedURI   *webAppURI
+		wantedError error
+	}{
+		"environment does not exist in store": {
+			mockStore: func(ctrl *gomock.Controller) *mocks.MockenvAppGetter {
+				m := mocks.NewMockenvAppGetter(ctrl)
+				m.EXPECT().GetEnvironment(testProject, testEnv).Return(nil, errors.New("some error"))
+				return m
+			},
+			mockStackDescribers: func(ctrl *gomock.Controller) map[string]stackDescriber {
+				return nil
+			},
+			wantedError: errors.New("some error"),
+		},
+		"cfn error": {
+			mockStore: func(ctrl *gomock.Controller) *mocks.MockenvAppGetter {
+				m := mocks.NewMockenvAppGetter(ctrl)
+				m.EXPECT().GetEnvironment(testProject, testEnv).Return(&archer.Environment{
+					Project:        testProject,
+					Name:           testEnv,
+					ManagerRoleARN: testManagerRoleARN,
+				}, nil)
+				return m
+			},
+			mockStackDescribers: func(ctrl *gomock.Controller) map[string]stackDescriber {
+				m := mocks.NewMockstackDescriber(ctrl)
+				describers := make(map[string]stackDescriber)
+				m.EXPECT().DescribeStacks(gomock.Any()).Return(nil, errors.New("some error"))
+				describers[testManagerRoleARN] = m
+				return describers
+			},
+			wantedError: fmt.Errorf("describe stack %s: %s", stack.NameForEnv(testProject, testEnv), "some error"),
+		},
+		"stack does not exist": {
+			mockStore: func(ctrl *gomock.Controller) *mocks.MockenvAppGetter {
+				m := mocks.NewMockenvAppGetter(ctrl)
+				m.EXPECT().GetEnvironment(testProject, testEnv).Return(&archer.Environment{
+					Project:        testProject,
+					Name:           testEnv,
+					ManagerRoleARN: testManagerRoleARN,
+				}, nil)
+				return m
+			},
+			mockStackDescribers: func(ctrl *gomock.Controller) map[string]stackDescriber {
+				m := mocks.NewMockstackDescriber(ctrl)
+				describers := make(map[string]stackDescriber)
+				m.EXPECT().DescribeStacks(gomock.Any()).Return(&cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{},
+				}, nil)
+				describers[testManagerRoleARN] = m
+				return describers
+			},
+			wantedError: fmt.Errorf("stack %s not found", stack.NameForEnv(testProject, testEnv)),
+		},
+		"https web application": {
+			mockStore: func(ctrl *gomock.Controller) *mocks.MockenvAppGetter {
+				m := mocks.NewMockenvAppGetter(ctrl)
+				m.EXPECT().GetEnvironment(testProject, testEnv).Return(&archer.Environment{
+					Project:        testProject,
+					Name:           testEnv,
+					ManagerRoleARN: testManagerRoleARN,
+				}, nil)
+				return m
+			},
+			mockStackDescribers: func(ctrl *gomock.Controller) map[string]stackDescriber {
+				m := mocks.NewMockstackDescriber(ctrl)
+				describers := make(map[string]stackDescriber)
+				m.EXPECT().DescribeStacks(&cloudformation.DescribeStacksInput{
+					StackName: aws.String(stack.NameForEnv(testProject, testEnv)),
+				}).Return(&cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{
+						{
+							Outputs: []*cloudformation.Output{
+								{
+									OutputKey:   aws.String(stack.EnvOutputSubdomain),
+									OutputValue: aws.String(testEnvSubdomain),
+								},
+								{
+									OutputKey:   aws.String(stack.EnvOutputPublicLoadBalancerDNSName),
+									OutputValue: aws.String(testEnvLBDNSName),
+								},
+							},
+						},
+					},
+				}, nil)
+				m.EXPECT().DescribeStacks(&cloudformation.DescribeStacksInput{
+					StackName: aws.String(stack.NameForApp(testProject, testEnv, testApp)),
+				}).Return(&cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{
+						{
+							Parameters: []*cloudformation.Parameter{
+								{
+									ParameterKey:   aws.String(stack.LBFargateRulePathKey),
+									ParameterValue: aws.String(testAppPath),
+								},
+							},
+						},
+					},
+				}, nil)
+				describers[testManagerRoleARN] = m
+				return describers
+			},
+
+			wantedURI: &webAppURI{
+				DNSName: testApp + "." + testEnvSubdomain,
+			},
+		},
+		"http web application": {
+			mockStore: func(ctrl *gomock.Controller) *mocks.MockenvAppGetter {
+				m := mocks.NewMockenvAppGetter(ctrl)
+				m.EXPECT().GetEnvironment(testProject, testEnv).Return(&archer.Environment{
+					Project:        testProject,
+					Name:           testEnv,
+					ManagerRoleARN: testManagerRoleARN,
+				}, nil)
+				return m
+			},
+			mockStackDescribers: func(ctrl *gomock.Controller) map[string]stackDescriber {
+				m := mocks.NewMockstackDescriber(ctrl)
+				describers := make(map[string]stackDescriber)
+				m.EXPECT().DescribeStacks(&cloudformation.DescribeStacksInput{
+					StackName: aws.String(stack.NameForEnv(testProject, testEnv)),
+				}).Return(&cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{
+						{
+							Outputs: []*cloudformation.Output{
+								{
+									OutputKey:   aws.String(stack.EnvOutputPublicLoadBalancerDNSName),
+									OutputValue: aws.String(testEnvLBDNSName),
+								},
+							},
+						},
+					},
+				}, nil)
+				m.EXPECT().DescribeStacks(&cloudformation.DescribeStacksInput{
+					StackName: aws.String(stack.NameForApp(testProject, testEnv, testApp)),
+				}).Return(&cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{
+						{
+							Parameters: []*cloudformation.Parameter{
+								{
+									ParameterKey:   aws.String(stack.LBFargateRulePathKey),
+									ParameterValue: aws.String(testAppPath),
+								},
+							},
+						},
+					},
+				}, nil)
+				describers[testManagerRoleARN] = m
+				return describers
+			},
+
+			wantedURI: &webAppURI{
+				DNSName: testEnvLBDNSName,
+				Path:    testAppPath,
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// GIVEN
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			d := &webAppDescriber{
+				app: &archer.Application{
+					Project: testProject,
+					Name:    testApp,
+				},
+				store:           tc.mockStore(ctrl),
+				stackDescribers: tc.mockStackDescribers(ctrl),
+			}
+
+			// WHEN
+			actual, err := d.URI(testEnv)
+
+			// THEN
+			if tc.wantedError != nil {
+				require.EqualError(t, err, tc.wantedError.Error())
+			} else {
+				require.Nil(t, err)
+				require.Equal(t, tc.wantedURI.String(), actual)
+			}
+		})
+	}
+}