Repository: aws/amazon-ecs-cli-v2
Pull request #272 (2019-11-04 18:06:13)

Add app deploy subcommand
<!-- Provide summary of changes -->
* Removed the `-v` flag in the `run-unit-test` `Makefile` target to make viewing the output easier
* Added the `app deploy` sub-command
* Added a few independent clients that the `app deploy` sub-command depends on
  * `ecr`, `docker`, and `project` packages
* Modified the `listDockerfiles` function to return the housing directories of the `Dockerfile`s
* Extended the `Manifest` interface to include a `DockerfilePath` method

<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->
#230 

### TODOs
* make the `Dockerfile` paths resolve regardless of where `app deploy` is run - #277 
* remove ECR repository creation logic once ECR repository can be assumed to already exist
* wrap `os\exec` calls elsewhere to make it mockable

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 6665020c..8d8585bf 100644
--- a/Makefile
+++ b/Makefile
@@ -40,7 +40,7 @@ packr-clean: tools
 test: packr-build run-unit-test packr-clean
 
 run-unit-test:
-	go test -v -race -cover -count=1 -coverprofile ${COVERAGE} ${PACKAGES}
+	go test -race -cover -count=1 -coverprofile ${COVERAGE} ${PACKAGES}
 
 generate-coverage: ${COVERAGE}
 	go tool cover -html=${COVERAGE}
@@ -99,3 +99,4 @@ gen-mocks: tools
 	${GOBIN}/mockgen -source=./internal/pkg/cli/completion.go -package=mocks -destination=./internal/pkg/cli/mocks/mock_completion.go
 	${GOBIN}/mockgen -source=./internal/pkg/cli/identity.go -package=mocks -destination=./internal/pkg/cli/mocks/mock_identity.go
 	${GOBIN}/mockgen -source=./internal/pkg/cli/env.go -package=mocks -destination=./internal/pkg/cli/mocks/mock_env.go
+	${GOBIN}/mockgen -source=./internal/pkg/cli/app_deploy.go -package=mocks -destination=./internal/pkg/cli/mocks/mock_projectservice.go
diff --git a/go.sum b/go.sum
index 0229537d..378207b8 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,6 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
-github.com/aws/aws-sdk-go v1.25.19 h1:sp3xP91qIAVhWufyn9qM6Zhhn6kX06WJQcmhRj7QTXc=
-github.com/aws/aws-sdk-go v1.25.19/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/aws/aws-sdk-go v1.25.25 h1:j3HLOqcDWjNox1DyvJRs+kVQF42Ghtv6oL6cVBfXS3U=
 github.com/aws/aws-sdk-go v1.25.25/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -50,7 +48,6 @@ github.com/gobuffalo/logger v1.0.1 h1:ZEgyRGgAm4ZAhAO45YXMs5Fp+bzGLESFewzAVBMKuT
 github.com/gobuffalo/logger v1.0.1/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
 github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4=
 github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
-github.com/gobuffalo/packr v1.30.1 h1:hu1fuVR3fXEZR7rXNW3h8rqSML8EVAf6KNm0NKO/wKg=
 github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o=
 github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc=
 github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
diff --git a/internal/pkg/archer/manifest.go b/internal/pkg/archer/manifest.go
index 52596d7d..d3799ee0 100644
--- a/internal/pkg/archer/manifest.go
+++ b/internal/pkg/archer/manifest.go
@@ -6,4 +6,5 @@ package archer
 // Manifest is the interface for serializing a manifest object to a YAML document or CloudFormation template.
 type Manifest interface {
 	Marshal() ([]byte, error)
+	DockerfilePath() string
 }
diff --git a/internal/pkg/build/docker/docker.go b/internal/pkg/build/docker/docker.go
new file mode 100644
index 00000000..ac7bbe48
--- /dev/null
+++ b/internal/pkg/build/docker/docker.go
@@ -0,0 +1,80 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package docker
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"strings"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/log"
+)
+
+// TODO: wrap the `os/exec` stuff in a `command` package to make mocking and testing easier on this package.
+
+// Service wraps a repository URI endpoint.
+type Service struct {
+	repositoryURI string
+}
+
+// New returns a Service configured with the input URI.
+func New(uri string) Service {
+	return Service{
+		repositoryURI: uri,
+	}
+}
+
+// Build will `os/exec` a `docker build` command with the input tag and Dockerfile image path.
+func (s Service) Build(imageTag, path string) error {
+	imageName := fmt.Sprintf("%s:%s", s.repositoryURI, imageTag)
+
+	cmd := newCommand("docker", "build", "-t", imageName, path)
+
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("building image: %w", err)
+	}
+
+	return nil
+}
+
+// Login will `os/exec` a `docker login` command against the Service repository URI with the input auth data.
+func (s Service) Login(auth ecr.Auth) error {
+	cmd := newCommand("docker", "login", "-u", auth.Username, "--password-stdin", s.repositoryURI)
+	cmd.Stdin = strings.NewReader(auth.Password)
+
+	if err := cmd.Run(); err != nil {
+		return fmt.Errorf("authenticate to ECR: %w", err)
+	}
+
+	return nil
+}
+
+// Push will `os/exec` a `docker push` command against the Service repository URI with the input image tag.
+func (s Service) Push(imageTag string) error {
+	path := s.repositoryURI + ":" + imageTag
+
+	cmd := newCommand("docker", "push", path)
+
+	if err := cmd.Run(); err != nil {
+		// TODO: improve the error handling here.
+		// if you try to push an *existing* image that has Digest A and tag T then no error (also no image push).
+		// if you try to push an *existing* image that has Digest B and tag T (that belongs to another image Digest A) then docker spits out an unclear error.
+		log.Warningf("the image with tag %s may already exist.\n", imageTag)
+
+		return fmt.Errorf("docker push: %w", err)
+	}
+
+	return nil
+}
+
+func newCommand(name string, args ...string) *exec.Cmd {
+	cmd := exec.Command(name, args...)
+	// NOTE: Stdout and Stderr must both be set otherwise command output pipes to os.DevNull
+	cmd.Stdout = os.Stderr
+	cmd.Stderr = os.Stderr
+
+	return cmd
+}
diff --git a/internal/pkg/build/ecr/ecr.go b/internal/pkg/build/ecr/ecr.go
new file mode 100644
index 00000000..e9cc1cd2
--- /dev/null
+++ b/internal/pkg/build/ecr/ecr.go
@@ -0,0 +1,92 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package ecr wraps AWS Elastic Container Registry (ECR) functionality.
+package ecr
+
+import (
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/aws/aws-sdk-go/service/ecr/ecriface"
+)
+
+// Service wraps an AWS ECR client.
+type Service struct {
+	ecr ecriface.ECRAPI
+}
+
+// New returns a Service configured against the input session.
+func New(s *session.Session) Service {
+	return Service{
+		ecr: ecr.New(s),
+	}
+}
+
+// Auth represent basic authentication credentials.
+type Auth struct {
+	Username string
+	Password string
+}
+
+// GetECRAuth returns the basic authentication credentials needed to push images.
+func (s Service) GetECRAuth() (Auth, error) {
+	response, err := s.ecr.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+	if err != nil {
+		return Auth{}, fmt.Errorf("get ECR auth: %w", err)
+	}
+
+	authToken, err := base64.StdEncoding.DecodeString(*response.AuthorizationData[0].AuthorizationToken)
+
+	if err != nil {
+		return Auth{}, fmt.Errorf("decode auth token: %w", err)
+	}
+
+	tokenStrings := strings.Split(string(authToken), ":")
+
+	return Auth{
+		Username: tokenStrings[0],
+		Password: tokenStrings[1],
+	}, nil
+}
+
+// CreateRepository creates an ECR repository with input name and returns the repository URI.
+func (s Service) CreateRepository(name string) (string, error) {
+	result, err := s.ecr.CreateRepository(&ecr.CreateRepositoryInput{
+		RepositoryName:     aws.String(name),
+		ImageTagMutability: aws.String("IMMUTABLE"),
+	})
+
+	if err != nil {
+		return "", fmt.Errorf("create repository: %w", err)
+	}
+
+	return *result.Repository.RepositoryUri, nil
+}
+
+// GetRepository returns the ECR repository URI.
+func (s Service) GetRepository(name string) (string, error) {
+	result, err := s.ecr.DescribeRepositories(&ecr.DescribeRepositoriesInput{
+		RepositoryNames: aws.StringSlice([]string{name}),
+	})
+
+	if err != nil {
+		return "", fmt.Errorf("repository %s not found: %w", name, err)
+	}
+
+	foundRepositories := result.Repositories
+
+	if len(foundRepositories) <= 0 {
+		return "", errors.New("no repositories found")
+	}
+
+	repo := result.Repositories[0]
+
+	return *repo.RepositoryUri, nil
+}
diff --git a/internal/pkg/cli/app.go b/internal/pkg/cli/app.go
index 3053bddc..a2e2ed6e 100644
--- a/internal/pkg/cli/app.go
+++ b/internal/pkg/cli/app.go
@@ -27,6 +27,7 @@ An application represents an Amazon ECS service or task.`,
 
 	cmd.AddCommand(BuildAppInitCmd())
 	cmd.AddCommand(BuildAppPackageCmd())
+	cmd.AddCommand(BuildAppDeployCommand())
 	cmd.SetUsageTemplate(template.Usage)
 	cmd.Annotations = map[string]string{
 		"group": group.Develop,
diff --git a/internal/pkg/cli/app_deploy.go b/internal/pkg/cli/app_deploy.go
new file mode 100644
index 00000000..ef0f4cf9
--- /dev/null
+++ b/internal/pkg/cli/app_deploy.go
@@ -0,0 +1,446 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cli
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os/exec"
+	"strings"
+
+	"github.com/spf13/cobra"
+
+	"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/build/docker"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/store/ssm"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/log"
+	termprogress "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/progress"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/prompt"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/workspace"
+)
+
+// BuildAppDeployCommand builds the `app deploy` subcommand.
+func BuildAppDeployCommand() *cobra.Command {
+	input := &appDeployOpts{
+		globalOpts: newGlobalOpts(),
+		prompt:     prompt.New(),
+		spinner:    termprogress.NewSpinner(),
+	}
+
+	cmd := &cobra.Command{
+		Use:  "deploy",
+		Long: `Deploy an application to an environment.`,
+		Example: `
+  Deploy an application named "frontend" to a "test" environment.
+  $ archer app deploy --name frontend --env test`,
+		PreRunE: func(cmd *cobra.Command, args []string) error {
+			if err := input.init(); err != nil {
+				return err
+			}
+
+			if err := input.sourceInputs(); err != nil {
+				return err
+			}
+
+			return nil
+		},
+		RunE: func(cmd *cobra.Command, args []string) error {
+			if err := input.deployApp(); err != nil {
+				return err
+			}
+
+			return nil
+		},
+		// PostRunE: func(cmd *cobra.Command, args []string) error {
+		// TODO: recommended actions?
+		// },
+	}
+
+	cmd.Flags().StringVarP(&input.app, "app", "a", "", "application name")
+	cmd.Flags().StringVarP(&input.env, "env", "e", "", "environment name")
+	cmd.Flags().StringVarP(&input.imageTag, "tag", "t", "", "image tag")
+
+	return cmd
+}
+
+type appDeployOpts struct {
+	globalOpts
+	app      string
+	env      string
+	imageTag string
+
+	projectService   projectService
+	ecrService       ecr.Service
+	workspaceService *workspace.Workspace
+
+	prompt  prompter
+	spinner progress
+
+	projectApplications []*archer.Application
+	projectEnvironments []*archer.Environment
+}
+
+func (opts appDeployOpts) String() string {
+	return fmt.Sprintf("project: %s, app: %s, env: %s, tag: %s", opts.projectName, opts.app, opts.env, opts.imageTag)
+}
+
+type projectService interface {
+	archer.ProjectStore
+	archer.EnvironmentStore
+	archer.ApplicationStore
+}
+
+func (opts *appDeployOpts) init() error {
+	projectService, err := ssm.NewStore()
+	if err != nil {
+		return fmt.Errorf("create project service: %w", err)
+	}
+	opts.projectService = projectService
+
+	// TODO: toolsAccountSession may need to be regionalized?
+	toolsAccountSession, err := session.Default()
+	if err != nil {
+		return fmt.Errorf("initialize tools account session: %w", err)
+	}
+	opts.ecrService = ecr.New(toolsAccountSession)
+
+	workspaceService, err := workspace.New()
+	if err != nil {
+		return fmt.Errorf("intialize workspace service: %w", err)
+	}
+	opts.workspaceService = workspaceService
+
+	return nil
+}
+
+func (opts *appDeployOpts) sourceInputs() error {
+	if opts.projectName == "" {
+		return errNoProjectInWorkspace
+	}
+
+	if err := opts.sourceProjectData(); err != nil {
+		return err
+	}
+
+	if err := opts.sourceAppName(); err != nil {
+		return err
+	}
+
+	if err := opts.sourceEnvName(); err != nil {
+		return err
+	}
+
+	if err := opts.sourceImageTag(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (opts *appDeployOpts) sourceProjectData() error {
+	if err := opts.sourceProjectApplications(); err != nil {
+		return err
+	}
+
+	if err := opts.sourceProjectEnvironments(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (opts *appDeployOpts) sourceProjectApplications() error {
+	apps, err := opts.projectService.ListApplications(opts.projectName)
+
+	if err != nil {
+		return fmt.Errorf("get apps: %w", err)
+	}
+
+	if len(apps) == 0 {
+		// TODO: recommend follow up command - app init?
+		return errors.New("no applications found")
+	}
+
+	opts.projectApplications = apps
+
+	return nil
+}
+
+func (opts *appDeployOpts) sourceProjectEnvironments() error {
+	envs, err := opts.projectService.ListEnvironments(opts.projectName)
+
+	if err != nil {
+		return fmt.Errorf("get environments: %w", err)
+	}
+
+	if len(envs) == 0 {
+		// TODO: recommend follow up command - env init?
+		log.Infof("couldn't find any environments associated with project %s, try initializing one: %s\n",
+			color.HighlightUserInput(opts.projectName),
+			color.HighlightCode("archer env init"))
+
+		return errors.New("no environments found")
+	}
+
+	opts.projectEnvironments = envs
+
+	return nil
+}
+
+func (opts *appDeployOpts) sourceAppName() error {
+	appNames := []string{}
+
+	// TODO: limit application options to only those in the local workspace
+	for _, app := range opts.projectApplications {
+		appNames = append(appNames, app.Name)
+	}
+
+	if opts.app == "" {
+		if len(appNames) == 1 {
+			opts.app = appNames[0]
+
+			// NOTE: defaulting the app name, tell the user
+			log.Infof("Only found one app, defaulting to: %s\n", color.HighlightUserInput(opts.app))
+
+			return nil
+		}
+
+		selectedAppName, err := opts.prompt.SelectOne("Select an application", "", appNames)
+
+		if err != nil {
+			return fmt.Errorf("select app name: %w", err)
+		}
+
+		opts.app = selectedAppName
+	}
+
+	for _, appName := range appNames {
+		if opts.app == appName {
+			return nil
+		}
+	}
+
+	return fmt.Errorf("invalid app name")
+}
+
+func (opts *appDeployOpts) sourceEnvName() error {
+	envNames := []string{}
+
+	for _, env := range opts.projectEnvironments {
+		envNames = append(envNames, env.Name)
+	}
+
+	if opts.env == "" {
+		if len(envNames) == 1 {
+			opts.env = envNames[0]
+
+			// NOTE: defaulting the env name, tell the user.
+			log.Infof("Only found one environment, defaulting to: %s\n", color.HighlightUserInput(opts.env))
+
+			return nil
+		}
+
+		selectedEnvName, err := opts.prompt.SelectOne("Select an environment", "", envNames)
+
+		if err != nil {
+			return fmt.Errorf("select env name: %w", err)
+		}
+
+		opts.env = selectedEnvName
+	}
+
+	for _, envName := range envNames {
+		if opts.env == envName {
+			return nil
+		}
+	}
+
+	return fmt.Errorf("invalid env name")
+}
+
+func (opts *appDeployOpts) sourceImageTag() error {
+	if opts.imageTag != "" {
+		return nil
+	}
+
+	cmd := exec.Command("git", "describe", "--always")
+
+	bytes, err := cmd.Output()
+
+	if err != nil {
+		return fmt.Errorf("defaulting tag: %w", err)
+	}
+
+	// NOTE: `git describe` output bytes includes a `\n` character, so we trim it out.
+	opts.imageTag = strings.TrimSpace(string(bytes))
+
+	return nil
+}
+
+func (opts appDeployOpts) deployApp() error {
+	// TODO: remove ECR repository creation
+	// Ideally this `getRepositoryURI` flow will not create an ECR repository, one will exist after the `app init` workflow.
+	uri, err := getRepositoryURI(opts.projectName, opts.app)
+	if err != nil {
+		return fmt.Errorf("get repository URI: %w", err)
+	}
+
+	dockerService := docker.New(uri)
+
+	appDockerfilePath, err := opts.getAppDockerfilePath()
+	if err != nil {
+		return err
+	}
+
+	if err := dockerService.Build(opts.imageTag, appDockerfilePath); err != nil {
+		return fmt.Errorf("build Dockerfile at %s with tag %s, %w", appDockerfilePath, opts.imageTag, err)
+	}
+
+	auth, err := opts.ecrService.GetECRAuth()
+
+	if err != nil {
+		return fmt.Errorf("get ECR auth data: %w", err)
+	}
+
+	dockerService.Login(auth)
+
+	if err != nil {
+		return err
+	}
+
+	if err = dockerService.Push(opts.imageTag); err != nil {
+		return err
+	}
+
+	template, err := opts.getAppDeployTemplate()
+
+	stackName := fmt.Sprintf("%s-%s", opts.app, opts.env)
+	changeSetName := fmt.Sprintf("%s-%s", stackName, opts.imageTag)
+
+	opts.spinner.Start(fmt.Sprintf("Deploying %s to %s.",
+		fmt.Sprintf("%s:%s", color.HighlightUserInput(opts.app),
+			color.HighlightUserInput(opts.imageTag)),
+		color.HighlightUserInput(opts.env)))
+
+	if err := applyAppDeployTemplate(template, stackName, changeSetName); err != nil {
+		opts.spinner.Stop("Error!")
+
+		return err
+	}
+
+	opts.spinner.Stop("Done!")
+
+	log.Successf("Deployed %s to %s.\n", fmt.Sprintf("%s:%s", color.HighlightUserInput(opts.app),
+		color.HighlightUserInput(opts.imageTag)),
+		color.HighlightUserInput(opts.env))
+
+	return nil
+}
+
+func (opts appDeployOpts) getAppDeployTemplate() (string, error) {
+	buffer := &bytes.Buffer{}
+
+	appPackage := PackageAppOpts{
+		AppName:      opts.app,
+		EnvName:      opts.env,
+		Tag:          opts.imageTag,
+		stackWriter:  buffer,
+		paramsWriter: ioutil.Discard,
+		envStore:     opts.projectService,
+		ws:           opts.workspaceService,
+		globalOpts:   opts.globalOpts,
+	}
+
+	if err := appPackage.Execute(); err != nil {
+		return "", fmt.Errorf("package application: %w", err)
+	}
+
+	return buffer.String(), nil
+}
+
+func applyAppDeployTemplate(template, stackName, changeSetName string) error {
+	// TODO: create a session from the environment profile to support cross-account?
+	session, err := session.Default()
+	if err != nil {
+		// TODO: handle err
+		return err
+	}
+
+	cfClient := cloudformation.New(session)
+
+	if err := cfClient.DeployApp(template, stackName, changeSetName); err != nil {
+		return fmt.Errorf("deploy application: %w", err)
+	}
+
+	return nil
+}
+
+func getRepositoryURI(projectName, appName string) (string, error) {
+	sess, err := session.Default()
+
+	if err != nil {
+		return "", err
+	}
+
+	ecrService := ecr.New(sess)
+
+	// assume the ECR repository name is the projectName/appName
+	repoName := fmt.Sprintf("%s/%s", projectName, appName)
+
+	// try to describe the repository to see if it exists
+	// NOTE: this should go away once ECR repositories are managed elsewhere
+	uri, err := ecrService.GetRepository(repoName)
+	// if there was an error assume the repo doesn't exist and try to create it
+	if err == nil {
+		return uri, nil
+	}
+
+	uri, err = ecrService.CreateRepository(repoName)
+
+	if err != nil {
+		return "", err
+	}
+
+	return uri, nil
+}
+
+func (opts appDeployOpts) getAppDockerfilePath() (string, error) {
+	manifestFileNames, err := opts.workspaceService.ListManifestFiles()
+	if err != nil {
+		return "", err
+	}
+	if len(manifestFileNames) == 0 {
+		return "", errors.New("no manifest files found")
+	}
+
+	var targetManifestFile string
+	for _, f := range manifestFileNames {
+		if strings.Contains(f, opts.app) {
+			targetManifestFile = f
+			break
+		}
+	}
+	if targetManifestFile == "" {
+		return "", errors.New("couldn't match manifest file name")
+	}
+
+	manifestBytes, err := opts.workspaceService.ReadManifestFile(targetManifestFile)
+	if err != nil {
+		return "", err
+	}
+
+	mf, err := manifest.UnmarshalApp(manifestBytes)
+	if err != nil {
+		return "", err
+	}
+
+	return mf.DockerfilePath(), nil
+}
diff --git a/internal/pkg/cli/app_init.go b/internal/pkg/cli/app_init.go
index 07b5194e..9af72127 100644
--- a/internal/pkg/cli/app_init.go
+++ b/internal/pkg/cli/app_init.go
@@ -174,7 +174,7 @@ Deployed resources (such as your service, logs) will contain this app's name and
 // If the user chooses to enter a custom path, then we prompt them for the path.
 func (opts *InitAppOpts) askDockerfile() error {
 	// TODO https://github.com/aws/amazon-ecs-cli-v2/issues/206
-	dockerfiles, err := opts.listDockerfiles()
+	dockerfiles, err := opts.listDockerfileDirs()
 	if err != nil {
 		return err
 	}
@@ -202,37 +202,43 @@ func (opts *InitAppOpts) askDockerfile() error {
 	return nil
 }
 
-// listDockerfiles returns the list of Dockerfiles within the current working directory and a sub-directory level below.
+// listDockerfileDirs returns the list of directories containing Dockerfiles within
+// the current working directory and a sub-directory level below.
 // If an error occurs while reading directories, returns the error.
-func (opts *InitAppOpts) listDockerfiles() ([]string, error) {
+func (opts *InitAppOpts) listDockerfileDirs() ([]string, error) {
 	wdFiles, err := afero.ReadDir(opts.fs, ".")
 	if err != nil {
 		return nil, fmt.Errorf("read directory: %w", err)
 	}
-	var dockerfiles []string
+	var directories []string
 
 	for _, wdFile := range wdFiles {
-		// Add Dockerfiles in current directory, otherwise continue.
+		// Add current directory if a Dockerfile exists, otherwise continue.
 		if !wdFile.IsDir() {
 			if wdFile.Name() == "Dockerfile" {
-				dockerfiles = append(dockerfiles, filepath.Join(".", wdFile.Name()))
+				directories = append(directories, filepath.Dir(wdFile.Name()))
 			}
 			continue
 		}
 
-		// Add Dockerfiles in one sub-directory below.
-		subFiles, err := afero.ReadDir(opts.fs, filepath.Join(".", wdFile.Name()))
+		// Add sub-directories containing a Dockerfile one level below current directory.
+		subFiles, err := afero.ReadDir(opts.fs, wdFile.Name())
 		if err != nil {
 			return nil, fmt.Errorf("read directory: %w", err)
 		}
 		for _, f := range subFiles {
+			// NOTE: ignore directories in sub-directories.
+			if f.IsDir() {
+				continue
+			}
+
 			if f.Name() == "Dockerfile" {
-				dockerfiles = append(dockerfiles, filepath.Join(".", wdFile.Name(), "Dockerfile"))
+				directories = append(directories, wdFile.Name())
 			}
 		}
 	}
-	sort.Strings(dockerfiles)
-	return dockerfiles, nil
+	sort.Strings(directories)
+	return directories, nil
 }
 
 func (opts *InitAppOpts) ensureNoExistingApp(projectName, appName string) error {
diff --git a/internal/pkg/cli/mocks/mock_projectservice.go b/internal/pkg/cli/mocks/mock_projectservice.go
new file mode 100644
index 00000000..dca7c941
--- /dev/null
+++ b/internal/pkg/cli/mocks/mock_projectservice.go
@@ -0,0 +1,166 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: ./internal/pkg/cli/app_deploy.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	archer "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	gomock "github.com/golang/mock/gomock"
+	reflect "reflect"
+)
+
+// MockprojectService is a mock of projectService interface
+type MockprojectService struct {
+	ctrl     *gomock.Controller
+	recorder *MockprojectServiceMockRecorder
+}
+
+// MockprojectServiceMockRecorder is the mock recorder for MockprojectService
+type MockprojectServiceMockRecorder struct {
+	mock *MockprojectService
+}
+
+// NewMockprojectService creates a new mock instance
+func NewMockprojectService(ctrl *gomock.Controller) *MockprojectService {
+	mock := &MockprojectService{ctrl: ctrl}
+	mock.recorder = &MockprojectServiceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockprojectService) EXPECT() *MockprojectServiceMockRecorder {
+	return m.recorder
+}
+
+// ListProjects mocks base method
+func (m *MockprojectService) ListProjects() ([]*archer.Project, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListProjects")
+	ret0, _ := ret[0].([]*archer.Project)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListProjects indicates an expected call of ListProjects
+func (mr *MockprojectServiceMockRecorder) ListProjects() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListProjects", reflect.TypeOf((*MockprojectService)(nil).ListProjects))
+}
+
+// GetProject mocks base method
+func (m *MockprojectService) GetProject(projectName string) (*archer.Project, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetProject", projectName)
+	ret0, _ := ret[0].(*archer.Project)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetProject indicates an expected call of GetProject
+func (mr *MockprojectServiceMockRecorder) GetProject(projectName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProject", reflect.TypeOf((*MockprojectService)(nil).GetProject), projectName)
+}
+
+// CreateProject mocks base method
+func (m *MockprojectService) CreateProject(project *archer.Project) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CreateProject", project)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// CreateProject indicates an expected call of CreateProject
+func (mr *MockprojectServiceMockRecorder) CreateProject(project interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateProject", reflect.TypeOf((*MockprojectService)(nil).CreateProject), project)
+}
+
+// ListEnvironments mocks base method
+func (m *MockprojectService) ListEnvironments(projectName string) ([]*archer.Environment, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListEnvironments", projectName)
+	ret0, _ := ret[0].([]*archer.Environment)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListEnvironments indicates an expected call of ListEnvironments
+func (mr *MockprojectServiceMockRecorder) ListEnvironments(projectName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListEnvironments", reflect.TypeOf((*MockprojectService)(nil).ListEnvironments), projectName)
+}
+
+// GetEnvironment mocks base method
+func (m *MockprojectService) 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 *MockprojectServiceMockRecorder) GetEnvironment(projectName, environmentName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEnvironment", reflect.TypeOf((*MockprojectService)(nil).GetEnvironment), projectName, environmentName)
+}
+
+// CreateEnvironment mocks base method
+func (m *MockprojectService) CreateEnvironment(env *archer.Environment) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CreateEnvironment", env)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// CreateEnvironment indicates an expected call of CreateEnvironment
+func (mr *MockprojectServiceMockRecorder) CreateEnvironment(env interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateEnvironment", reflect.TypeOf((*MockprojectService)(nil).CreateEnvironment), env)
+}
+
+// ListApplications mocks base method
+func (m *MockprojectService) ListApplications(projectName string) ([]*archer.Application, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListApplications", projectName)
+	ret0, _ := ret[0].([]*archer.Application)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListApplications indicates an expected call of ListApplications
+func (mr *MockprojectServiceMockRecorder) ListApplications(projectName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListApplications", reflect.TypeOf((*MockprojectService)(nil).ListApplications), projectName)
+}
+
+// GetApplication mocks base method
+func (m *MockprojectService) 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 *MockprojectServiceMockRecorder) GetApplication(projectName, applicationName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetApplication", reflect.TypeOf((*MockprojectService)(nil).GetApplication), projectName, applicationName)
+}
+
+// CreateApplication mocks base method
+func (m *MockprojectService) CreateApplication(app *archer.Application) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CreateApplication", app)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// CreateApplication indicates an expected call of CreateApplication
+func (mr *MockprojectServiceMockRecorder) CreateApplication(app interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApplication", reflect.TypeOf((*MockprojectService)(nil).CreateApplication), app)
+}
diff --git a/internal/pkg/deploy/cloudformation/app.go b/internal/pkg/deploy/cloudformation/app.go
new file mode 100644
index 00000000..1443ee4a
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/app.go
@@ -0,0 +1,88 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cloudformation
+
+import (
+	"fmt"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/aws/awserr"
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+)
+
+// DeployApp wraps the application deployment flow and handles orchestration of
+// creating a stack versus updating a stack.
+func (cf CloudFormation) DeployApp(template, stackName, changeSetName string) error {
+	_, err := cf.client.CreateStack(&cloudformation.CreateStackInput{
+		StackName:    aws.String(stackName),
+		TemplateBody: aws.String(template),
+		Capabilities: aws.StringSlice([]string{cloudformation.CapabilityCapabilityIam}),
+	})
+
+	// If CreateStack does not return an error we'll wait for StackCreateComplete status.
+	if err == nil {
+		err = cf.client.WaitUntilStackCreateComplete(&cloudformation.DescribeStacksInput{
+			StackName: aws.String(stackName),
+		})
+
+		if err != nil {
+			return fmt.Errorf("wait for stack completion: %w", err)
+		}
+
+		return nil
+	}
+
+	// If error returned from CreateStack is ErrCodeAlreadyExistsException move on to CreateChangeSet flow.
+	if err != nil {
+		awsErr, ok := err.(awserr.Error)
+		if !ok {
+			return fmt.Errorf("create stack: %w", err)
+		}
+		if awsErr.Code() != cloudformation.ErrCodeAlreadyExistsException {
+			return fmt.Errorf("create stack: %w", err)
+		}
+	}
+
+	_, err = cf.client.CreateChangeSet(&cloudformation.CreateChangeSetInput{
+		ChangeSetName: aws.String(changeSetName),
+		StackName:     aws.String(stackName),
+		TemplateBody:  aws.String(template),
+		Capabilities:  aws.StringSlice([]string{cloudformation.CapabilityCapabilityIam}),
+		ChangeSetType: aws.String("UPDATE"),
+	})
+
+	if err != nil {
+		// TODO: gracefully handle stack already in UPDATE_IN_PROGRESS state
+		return fmt.Errorf("create change set: %w", err)
+	}
+
+	err = cf.client.WaitUntilChangeSetCreateComplete(&cloudformation.DescribeChangeSetInput{
+		ChangeSetName: aws.String(changeSetName),
+		StackName:     aws.String(stackName),
+	})
+
+	// NOTE: WaitUntilChangeSetCreateComplete just straight up fails if there are no changes to apply.
+	if err != nil {
+		// TODO: find a better way to handle no ChangeSet changes.
+		// Chances are here that there are no changes to apply, but it's janky to handle.
+		return fmt.Errorf("wait for change set create complete: %w", err)
+	}
+
+	_, err = cf.client.ExecuteChangeSet(&cloudformation.ExecuteChangeSetInput{
+		ChangeSetName: aws.String(changeSetName),
+		StackName:     aws.String(stackName),
+	})
+
+	if err != nil {
+		return fmt.Errorf("execute change set: %w", err)
+	}
+
+	if err := cf.client.WaitUntilStackUpdateComplete(&cloudformation.DescribeStacksInput{
+		StackName: aws.String(stackName),
+	}); err != nil {
+		return fmt.Errorf("wait for stack update: %w", err)
+	}
+
+	return nil
+}
diff --git a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
index 51943353..242efc69 100644
--- a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
+++ b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app.go
@@ -181,7 +181,7 @@ type lbFargateTemplateParams struct {
 }
 
 func (c *LBFargateStackConfig) toTemplateParams() *lbFargateTemplateParams {
-	imgLoc := fmt.Sprintf("%s/%s/%s:%s", c.Env.Project, c.Env.Name, c.App.Name, c.ImageTag)
+	imgLoc := fmt.Sprintf("%s/%s:%s", c.Env.Project, c.App.Name, c.ImageTag)
 	url := fmt.Sprintf(ecrURLFormatString, c.Env.AccountID, c.Env.Region, imgLoc)
 	return &lbFargateTemplateParams{
 		CreateLBFargateAppInput: &deploy.CreateLBFargateAppInput{
diff --git a/internal/pkg/manifest/lb_fargate_manifest.go b/internal/pkg/manifest/lb_fargate_manifest.go
index 61b11abb..c9ff42a6 100644
--- a/internal/pkg/manifest/lb_fargate_manifest.go
+++ b/internal/pkg/manifest/lb_fargate_manifest.go
@@ -100,6 +100,11 @@ func (m *LBFargateManifest) Marshal() ([]byte, error) {
 	return buf.Bytes(), nil
 }
 
+// DockerfilePath returns the image build path.
+func (m LBFargateManifest) DockerfilePath() string {
+	return m.Image.Build
+}
+
 // EnvConf returns the application configuration with environment overrides.
 // If the environment passed in does not have any overrides then we return the default values.
 func (m *LBFargateManifest) EnvConf(envName string) LBFargateConfig {


Test patch:
diff --git a/internal/pkg/build/ecr/ecr_test.go b/internal/pkg/build/ecr/ecr_test.go
new file mode 100644
index 00000000..9b8b1c1c
--- /dev/null
+++ b/internal/pkg/build/ecr/ecr_test.go
@@ -0,0 +1,196 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package ecr
+
+import (
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/aws/aws-sdk-go/aws"
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/aws/aws-sdk-go/service/ecr/ecriface"
+	"github.com/stretchr/testify/require"
+)
+
+type mockECR struct {
+	ecriface.ECRAPI
+
+	mockGetAuthorizationToken func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+	mockCreateRepository      func(*ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error)
+	mockDescribeRepositories  func(*ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error)
+}
+
+func (m mockECR) GetAuthorizationToken(input *ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+	return m.mockGetAuthorizationToken(input)
+}
+
+func (m mockECR) CreateRepository(input *ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error) {
+	return m.mockCreateRepository(input)
+}
+
+func (m mockECR) DescribeRepositories(input *ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error) {
+	return m.mockDescribeRepositories(input)
+}
+
+func TestGetECRAuth(t *testing.T) {
+	mockError := errors.New("error")
+
+	mockUsername := "mockUsername"
+	mockPassword := "mockPassword"
+
+	encoded := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", mockUsername, mockPassword)))
+
+	testCases := map[string]struct {
+		mockGetAuthorizationToken func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+
+		wantAuth Auth
+		wantErr  error
+	}{
+		"should return wrapped error given error returned from GetAuthorizationToken": {
+			mockGetAuthorizationToken: func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+				return nil, mockError
+			},
+			wantErr: fmt.Errorf("get ECR auth: %w", mockError),
+		},
+		"should return Auth data": {
+			mockGetAuthorizationToken: func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error) {
+				return &ecr.GetAuthorizationTokenOutput{
+					AuthorizationData: []*ecr.AuthorizationData{
+						&ecr.AuthorizationData{
+							AuthorizationToken: aws.String(encoded),
+						},
+					},
+				}, nil
+			},
+			wantAuth: Auth{
+				Username: mockUsername,
+				Password: mockPassword,
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			service := Service{
+				mockECR{
+					mockGetAuthorizationToken: tc.mockGetAuthorizationToken,
+				},
+			}
+
+			gotAuth, gotErr := service.GetECRAuth()
+
+			require.Equal(t, tc.wantAuth, gotAuth)
+			require.Equal(t, tc.wantErr, gotErr)
+		})
+	}
+}
+
+func TestCreateRepository(t *testing.T) {
+	mockError := errors.New("error")
+
+	mockRepoName := "mockRepoName"
+	mockRepoURI := "mockRepoURI"
+
+	testCases := map[string]struct {
+		mockCreateRepository func(*ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error)
+
+		wantURI string
+		wantErr error
+	}{
+		"should return wrapped error given error returned from CreateRepository": {
+			mockCreateRepository: func(*ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error) {
+				return nil, mockError
+			},
+			wantErr: fmt.Errorf("create repository: %w", mockError),
+		},
+		"should return repository URI": {
+			mockCreateRepository: func(input *ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error) {
+				require.Equal(t, mockRepoName, *input.RepositoryName)
+				require.Equal(t, "IMMUTABLE", *input.ImageTagMutability)
+
+				return &ecr.CreateRepositoryOutput{
+					Repository: &ecr.Repository{
+						RepositoryUri: aws.String(mockRepoURI),
+					},
+				}, nil
+			},
+			wantURI: mockRepoURI,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			service := Service{
+				mockECR{
+					mockCreateRepository: tc.mockCreateRepository,
+				},
+			}
+
+			gotURI, gotErr := service.CreateRepository(mockRepoName)
+
+			require.Equal(t, tc.wantURI, gotURI)
+			require.Equal(t, tc.wantErr, gotErr)
+		})
+	}
+}
+
+func TestGetRepository(t *testing.T) {
+	mockError := errors.New("error")
+
+	mockRepoName := "mockRepoName"
+	mockRepoURI := "mockRepoURI"
+
+	testCases := map[string]struct {
+		mockDescribeRepositories func(*ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error)
+
+		wantURI string
+		wantErr error
+	}{
+		"should return wrapped error given error returned from DescribeRepositories": {
+			mockDescribeRepositories: func(*ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error) {
+				return nil, mockError
+			},
+			wantErr: fmt.Errorf("repository %s not found: %w", mockRepoName, mockError),
+		},
+		"should return error given no repositories returned in list": {
+			mockDescribeRepositories: func(input *ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error) {
+				return &ecr.DescribeRepositoriesOutput{
+					Repositories: []*ecr.Repository{},
+				}, nil
+			},
+			wantErr: errors.New("no repositories found"),
+		},
+		"should return repository URI": {
+			mockDescribeRepositories: func(input *ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error) {
+				require.Equal(t, mockRepoName, *input.RepositoryNames[0])
+
+				return &ecr.DescribeRepositoriesOutput{
+					Repositories: []*ecr.Repository{
+						&ecr.Repository{
+							RepositoryUri: aws.String(mockRepoURI),
+						},
+					},
+				}, nil
+			},
+			wantURI: mockRepoURI,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			service := Service{
+				mockECR{
+					mockDescribeRepositories: tc.mockDescribeRepositories,
+				},
+			}
+
+			gotURI, gotErr := service.GetRepository(mockRepoName)
+
+			require.Equal(t, tc.wantURI, gotURI)
+			require.Equal(t, tc.wantErr, gotErr)
+		})
+	}
+}
diff --git a/internal/pkg/cli/app_deploy_test.go b/internal/pkg/cli/app_deploy_test.go
new file mode 100644
index 00000000..402c09e8
--- /dev/null
+++ b/internal/pkg/cli/app_deploy_test.go
@@ -0,0 +1,286 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cli
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/cli/mocks"
+)
+
+func TestSourceInputs(t *testing.T) {
+	opts := appDeployOpts{}
+
+	got := opts.sourceInputs()
+
+	require.Equal(t, errNoProjectInWorkspace, got)
+}
+
+func TestSourceProjectApplications(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockProjectService := mocks.NewMockprojectService(ctrl)
+
+	mockProjectName := "mockProjectName"
+	mockError := errors.New("error")
+	mockApps := []*archer.Application{
+		&archer.Application{
+			Project: mockProjectName,
+		},
+	}
+
+	testCases := map[string]struct {
+		setupMocks func()
+
+		wantErr  error
+		wantApps []*archer.Application
+	}{
+		"should wrap error returned from ListApplications": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListApplications(gomock.Eq(mockProjectName)).Times(1).Return(nil, mockError)
+			},
+			wantErr: fmt.Errorf("get apps: %w", mockError),
+		},
+		"should return error given no apps returned": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListApplications(gomock.Eq(mockProjectName)).Times(1).Return([]*archer.Application{}, nil)
+			},
+			wantErr: errors.New("no applications found"),
+		},
+		"should set opts projectApplications field": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListApplications(gomock.Eq(mockProjectName)).Times(1).Return(mockApps, nil)
+			},
+			wantApps: mockApps,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.setupMocks()
+
+			opts := appDeployOpts{
+				globalOpts: globalOpts{
+					projectName: mockProjectName,
+				},
+				projectService: mockProjectService,
+			}
+
+			gotErr := opts.sourceProjectApplications()
+
+			require.Equal(t, tc.wantErr, gotErr)
+			require.Equal(t, tc.wantApps, opts.projectApplications)
+		})
+	}
+}
+
+func TestSourceProjectEnvironments(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockProjectService := mocks.NewMockprojectService(ctrl)
+
+	mockProjectName := "mockProjectName"
+	mockError := errors.New("error")
+	mockEnvs := []*archer.Environment{
+		&archer.Environment{
+			Project: mockProjectName,
+		},
+	}
+
+	testCases := map[string]struct {
+		setupMocks func()
+
+		wantErr  error
+		wantEnvs []*archer.Environment
+	}{
+		"should wrap error returned from ListEnvironments": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return(nil, mockError)
+			},
+			wantErr: fmt.Errorf("get environments: %w", mockError),
+		},
+		"should return error given no environments returned": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return([]*archer.Environment{}, nil)
+			},
+			wantErr: errors.New("no environments found"),
+		},
+		"should set the opts projectEnvironments field": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return(mockEnvs, nil)
+			},
+			wantEnvs: mockEnvs,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.setupMocks()
+
+			opts := appDeployOpts{
+				globalOpts: globalOpts{
+					projectName: mockProjectName,
+				},
+				projectService: mockProjectService,
+			}
+
+			gotErr := opts.sourceProjectEnvironments()
+
+			require.Equal(t, tc.wantErr, gotErr)
+			require.Equal(t, tc.wantEnvs, opts.projectEnvironments)
+		})
+	}
+}
+
+func TestSourceAppName(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPrompt := mocks.NewMockprompter(ctrl)
+
+	mockAppName := "myapp"
+
+	testCases := map[string]struct {
+		setupMocks func()
+
+		inputAppFlag        string
+		projectApplications []*archer.Application
+
+		wantAppName string
+		wantErr     error
+	}{
+		"should validate the input app flag name": {
+			setupMocks:   func() {},
+			inputAppFlag: mockAppName,
+			projectApplications: []*archer.Application{
+				&archer.Application{
+					Name: mockAppName,
+				},
+			},
+			wantAppName: mockAppName,
+		},
+		"should default the app name if there's only one option": {
+			setupMocks: func() {},
+			projectApplications: []*archer.Application{
+				&archer.Application{
+					Name: mockAppName,
+				},
+			},
+			wantAppName: mockAppName,
+		},
+		"should prompt the user to select an app if there's multiple options": {
+			setupMocks: func() {
+				mockPrompt.EXPECT().
+					SelectOne(gomock.Eq("Select an application"), gomock.Eq(""), gomock.Eq([]string{mockAppName, "anotherone"})).
+					Times(1).
+					Return(mockAppName, nil)
+			},
+			projectApplications: []*archer.Application{
+				&archer.Application{
+					Name: mockAppName,
+				},
+				&archer.Application{
+					Name: "anotherone",
+				},
+			},
+			wantAppName: mockAppName,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.setupMocks()
+
+			opts := appDeployOpts{
+				projectApplications: tc.projectApplications,
+				prompt:              mockPrompt,
+			}
+
+			gotErr := opts.sourceAppName()
+
+			require.Equal(t, tc.wantErr, gotErr)
+			require.Equal(t, tc.wantAppName, opts.app)
+		})
+	}
+}
+
+func TestSourceEnvName(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPrompt := mocks.NewMockprompter(ctrl)
+
+	mockEnvName := "test"
+
+	testCases := map[string]struct {
+		setupMocks func()
+
+		inputEnvFlag        string
+		projectEnvironments []*archer.Environment
+
+		wantEnvName string
+		wantErr     error
+	}{
+		"should validate the input env flag name": {
+			setupMocks:   func() {},
+			inputEnvFlag: mockEnvName,
+			projectEnvironments: []*archer.Environment{
+				&archer.Environment{
+					Name: mockEnvName,
+				},
+			},
+			wantEnvName: mockEnvName,
+		},
+		"should default the env name if there's only one option": {
+			setupMocks: func() {},
+			projectEnvironments: []*archer.Environment{
+				&archer.Environment{
+					Name: mockEnvName,
+				},
+			},
+			wantEnvName: mockEnvName,
+		},
+		"should prompt the user to select an env if there's multiple options": {
+			setupMocks: func() {
+				mockPrompt.EXPECT().
+					SelectOne(gomock.Eq("Select an environment"), gomock.Eq(""), gomock.Eq([]string{mockEnvName, "anotherone"})).
+					Times(1).
+					Return(mockEnvName, nil)
+			},
+			projectEnvironments: []*archer.Environment{
+				&archer.Environment{
+					Name: mockEnvName,
+				},
+				&archer.Environment{
+					Name: "anotherone",
+				},
+			},
+			wantEnvName: mockEnvName,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			tc.setupMocks()
+
+			opts := appDeployOpts{
+				projectEnvironments: tc.projectEnvironments,
+				prompt:              mockPrompt,
+			}
+
+			gotErr := opts.sourceEnvName()
+
+			require.Equal(t, tc.wantErr, gotErr)
+			require.Equal(t, tc.wantEnvName, opts.env)
+		})
+	}
+}
diff --git a/internal/pkg/cli/app_init_test.go b/internal/pkg/cli/app_init_test.go
index 56377200..f29e489e 100644
--- a/internal/pkg/cli/app_init_test.go
+++ b/internal/pkg/cli/app_init_test.go
@@ -71,9 +71,9 @@ func TestAppInitOpts_Ask(t *testing.T) {
 			mockPrompt: func(m *mocks.Mockprompter) {
 				m.EXPECT().SelectOne(gomock.Eq("Which Dockerfile would you like to use for frontend app?"), gomock.Any(), gomock.Eq(
 					[]string{
-						"Dockerfile",
-						"backend/Dockerfile",
-						"frontend/Dockerfile",
+						".",
+						"backend",
+						"frontend",
 						"Enter a custom path",
 					})).
 					Return(wantedDockerfilePath, nil)
diff --git a/internal/pkg/deploy/cloudformation/app_test.go b/internal/pkg/deploy/cloudformation/app_test.go
new file mode 100644
index 00000000..1643de0d
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/app_test.go
@@ -0,0 +1,115 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cloudformation
+
+import (
+	"testing"
+
+	"github.com/aws/aws-sdk-go/aws/awserr"
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDeployApp(t *testing.T) {
+	mockTemplate := "mockTemplate"
+	mockStackName := "mockStackName"
+	mockChangeSetName := "mockChangeSetName"
+	// mockError := errors.New("mockError")
+
+	testCases := map[string]struct {
+		mockCreateStack                      func(t *testing.T, in *cloudformation.CreateStackInput) (*cloudformation.CreateStackOutput, error)
+		mockWaitUntilStackCreateComplete     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
+		mockCreateChangeSet                  func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error)
+		mockWaitUntilChangeSetCreateComplete func(t *testing.T, in *cloudformation.DescribeChangeSetInput) error
+		mockExecuteChangeSet                 func(t *testing.T, in *cloudformation.ExecuteChangeSetInput) (*cloudformation.ExecuteChangeSetOutput, error)
+		mockWaitUntilStackUpdateComplete     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
+
+		wantErr error
+	}{
+		"should create the stack if one didn't exist already and wait for completion": {
+			mockCreateStack: func(t *testing.T, in *cloudformation.CreateStackInput) (*cloudformation.CreateStackOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+				require.Equal(t, mockTemplate, *in.TemplateBody)
+				require.Equal(t, cloudformation.CapabilityCapabilityIam, *in.Capabilities[0])
+
+				return &cloudformation.CreateStackOutput{}, nil
+			},
+			mockWaitUntilStackCreateComplete: func(t *testing.T, in *cloudformation.DescribeStacksInput) error {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return nil
+			},
+		},
+		"should create and execute change set if stack already exists": {
+			mockCreateStack: func(t *testing.T, in *cloudformation.CreateStackInput) (*cloudformation.CreateStackOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+				require.Equal(t, mockTemplate, *in.TemplateBody)
+				require.Equal(t, cloudformation.CapabilityCapabilityIam, *in.Capabilities[0])
+
+				return nil, awserr.New(cloudformation.ErrCodeAlreadyExistsException, "", nil)
+			},
+			mockCreateChangeSet: func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockChangeSetName, *in.ChangeSetName)
+				require.Equal(t, mockStackName, *in.StackName)
+				require.Equal(t, mockTemplate, *in.TemplateBody)
+				require.Equal(t, cloudformation.CapabilityCapabilityIam, *in.Capabilities[0])
+				require.Equal(t, "UPDATE", *in.ChangeSetType)
+
+				return &cloudformation.CreateChangeSetOutput{}, nil
+			},
+			mockWaitUntilChangeSetCreateComplete: func(t *testing.T, in *cloudformation.DescribeChangeSetInput) error {
+				t.Helper()
+
+				require.Equal(t, mockChangeSetName, *in.ChangeSetName)
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return nil
+			},
+			mockExecuteChangeSet: func(t *testing.T, in *cloudformation.ExecuteChangeSetInput) (*cloudformation.ExecuteChangeSetOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockChangeSetName, *in.ChangeSetName)
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return &cloudformation.ExecuteChangeSetOutput{}, nil
+			},
+			mockWaitUntilStackUpdateComplete: func(t *testing.T, in *cloudformation.DescribeStacksInput) error {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return nil
+			},
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			cf := CloudFormation{
+				client: mockCloudFormation{
+					t: t,
+
+					mockCreateStack:                      tc.mockCreateStack,
+					mockWaitUntilStackCreateComplete:     tc.mockWaitUntilStackCreateComplete,
+					mockCreateChangeSet:                  tc.mockCreateChangeSet,
+					mockWaitUntilChangeSetCreateComplete: tc.mockWaitUntilChangeSetCreateComplete,
+					mockExecuteChangeSet:                 tc.mockExecuteChangeSet,
+					mockWaitUntilStackUpdateComplete:     tc.mockWaitUntilStackUpdateComplete,
+				},
+			}
+
+			gotErr := cf.DeployApp(mockTemplate, mockStackName, mockChangeSetName)
+
+			require.Equal(t, tc.wantErr, gotErr)
+		})
+	}
+}
diff --git a/internal/pkg/deploy/cloudformation/cloudformation_test.go b/internal/pkg/deploy/cloudformation/cloudformation_test.go
index de3464d6..c378acfb 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation_test.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation_test.go
@@ -44,6 +44,8 @@ type mockCloudFormation struct {
 	mockCreateStackInstances             func(t *testing.T, in *cloudformation.CreateStackInstancesInput) (*cloudformation.CreateStackInstancesOutput, error)
 	mockDescribeStackSetOperation        func(t *testing.T, in *cloudformation.DescribeStackSetOperationInput) (*cloudformation.DescribeStackSetOperationOutput, error)
 	mockDescribeStackEvents              func(t *testing.T, in *cloudformation.DescribeStackEventsInput) (*cloudformation.DescribeStackEventsOutput, error)
+	mockCreateStack                      func(t *testing.T, in *cloudformation.CreateStackInput) (*cloudformation.CreateStackOutput, error)
+	mockWaitUntilStackUpdateComplete     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
 }
 
 func (cf mockCloudFormation) CreateChangeSet(in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error) {
@@ -98,6 +100,14 @@ func (cf mockCloudFormation) DescribeStackEvents(in *cloudformation.DescribeStac
 	return cf.mockDescribeStackEvents(cf.t, in)
 }
 
+func (cf mockCloudFormation) CreateStack(in *cloudformation.CreateStackInput) (*cloudformation.CreateStackOutput, error) {
+	return cf.mockCreateStack(cf.t, in)
+}
+
+func (cf mockCloudFormation) WaitUntilStackUpdateComplete(in *cloudformation.DescribeStacksInput) error {
+	return cf.mockWaitUntilStackUpdateComplete(cf.t, in)
+}
+
 type mockStackConfiguration struct {
 	mockTemplate   func() (string, error)
 	mockParameters func() []*cloudformation.Parameter
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 f4d0b7a2..e2a51fa9 100644
--- a/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go
+++ b/internal/pkg/deploy/cloudformation/stack/lb_fargate_app_test.go
@@ -115,7 +115,7 @@ func TestLBFargateStackConfig_Template(t *testing.T) {
   ProjectName: phonetool
   EnvName: test
   AppName: frontend
-  ContainerImage: 12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/test/frontend:manual-bf3678c
+  ContainerImage: 12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/frontend:manual-bf3678c
   ContainerPort: 80
   RulePriority: 1
   RulePath: '*'
@@ -181,7 +181,7 @@ func TestLBFargateStackConfig_Parameters(t *testing.T) {
 		},
 		{
 			ParameterKey:   aws.String(lbFargateParamContainerImageKey),
-			ParameterValue: aws.String("12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/test/frontend:manual-bf3678c"),
+			ParameterValue: aws.String("12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/frontend:manual-bf3678c"),
 		},
 		{
 			ParameterKey:   aws.String(lbFargateParamContainerPortKey),
@@ -262,7 +262,7 @@ func TestLBFargateStackConfig_SerializedParameters(t *testing.T) {
     "ProjectName" : "phonetool",
     "EnvName": "test",
     "AppName": "frontend",
-    "ContainerImage": "12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/test/frontend:manual-bf3678c",
+    "ContainerImage": "12345.dkr.ecr.us-west-2.amazonaws.com/phonetool/frontend:manual-bf3678c",
     "ContainerPort": "80",
     "RulePriority": "1",
     "RulePath": "*",