Repository: aws/amazon-ecs-cli-v2
Pull request #461 (2019-11-22 01:45:55)

Add app delete sub-command
<!-- Provide summary of changes -->
Add `app delete` functionality:
* Delete all app deployment stacks across associated environments
* Empty ECR repositories in all associated environment regions
* Update project resources to remove app ECR repositories
* Delete the app SSM parameter
* Delete the local workspace app manifest file

### TODOs
* Add test coverage on `app_delete.go` functions
* Minor TODOs in the code worth addressing

<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->
Addresses #322, #408 

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.



Patch:
diff --git a/internal/pkg/archer/app.go b/internal/pkg/archer/app.go
index c34d09fa..f4bdb720 100644
--- a/internal/pkg/archer/app.go
+++ b/internal/pkg/archer/app.go
@@ -15,6 +15,7 @@ type ApplicationStore interface {
 	ApplicationLister
 	ApplicationGetter
 	ApplicationCreator
+	ApplicationDeleter
 }
 
 // ApplicationLister fetches and returns a list of application from an underlying project management store
@@ -32,6 +33,11 @@ type ApplicationCreator interface {
 	CreateApplication(app *Application) error
 }
 
+// ApplicationDeleter deletes an application from the underlying project management store.
+type ApplicationDeleter interface {
+	DeleteApplication(projectName, appName string) error
+}
+
 const (
 	// AppCfnTemplateNameFormat is the base output file name when `app package`
 	// is called. This is also used to render the pipeline CFN template.
diff --git a/internal/pkg/archer/workspace.go b/internal/pkg/archer/workspace.go
index f77d48e0..7f6fd929 100644
--- a/internal/pkg/archer/workspace.go
+++ b/internal/pkg/archer/workspace.go
@@ -22,6 +22,7 @@ type ManifestIO interface {
 	WorkspaceFileReadWriter
 	ListManifestFiles() ([]string, error)
 	AppManifestFileName(appName string) string
+	DeleteFile(name string) error
 }
 
 // WorkspaceFileReadWriter is the interface to read and write files to the project directory in the workspace.
diff --git a/internal/pkg/aws/ecr/ecr.go b/internal/pkg/aws/ecr/ecr.go
index 47bf9449..a493a877 100644
--- a/internal/pkg/aws/ecr/ecr.go
+++ b/internal/pkg/aws/ecr/ecr.go
@@ -5,10 +5,16 @@
 package ecr
 
 import (
+	"encoding/base64"
+	"errors"
 	"fmt"
 	"strings"
 
+	"github.com/aws/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/aws/arn"
+	"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"
 )
 
 const (
@@ -16,6 +22,137 @@ const (
 	arnResourcePrefix = "repository/"
 )
 
+// 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
+}
+
+// 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("ecr describe repository %s: %w", name, err)
+	}
+
+	foundRepositories := result.Repositories
+
+	if len(foundRepositories) <= 0 {
+		return "", errors.New("no repositories found")
+	}
+
+	repo := result.Repositories[0]
+
+	return *repo.RepositoryUri, nil
+}
+
+// Image houses metadata for ECR repository images.
+type Image struct {
+	Digest string
+}
+
+func (i Image) imageIdentifier() *ecr.ImageIdentifier {
+	return &ecr.ImageIdentifier{
+		ImageDigest: aws.String(i.Digest),
+	}
+}
+
+// ListImages calls the ECR DescribeImages API and returns a list of
+// Image metadata for images in the input ECR repository name.
+func (s Service) ListImages(repoName string) ([]Image, error) {
+	// TODO: handle paginated responses
+	resp, err := s.ecr.DescribeImages(&ecr.DescribeImagesInput{
+		RepositoryName: aws.String(repoName),
+	})
+
+	if err != nil {
+		return nil, fmt.Errorf("ecr repo %s describe images: %w", repoName, err)
+	}
+
+	var images []Image
+	for _, imageDetails := range resp.ImageDetails {
+		images = append(images, Image{
+			Digest: *imageDetails.ImageDigest,
+		})
+	}
+
+	return images, nil
+}
+
+// DeleteImages calls the ECR BatchDeleteImage API with the input image list and repository name.
+func (s Service) DeleteImages(images []Image, repoName string) error {
+	if len(images) == 0 {
+		return nil
+	}
+
+	var imageIdentifiers []*ecr.ImageIdentifier
+	for _, image := range images {
+		imageIdentifiers = append(imageIdentifiers, image.imageIdentifier())
+	}
+
+	// TODO: handle response ecr.ImageFailures
+	_, err := s.ecr.BatchDeleteImage(&ecr.BatchDeleteImageInput{
+		RepositoryName: aws.String(repoName),
+		ImageIds:       imageIdentifiers,
+	})
+
+	if err != nil {
+		return fmt.Errorf("ecr repo %s batch delete image: %w", repoName, err)
+	}
+
+	return nil
+}
+
+// ClearRepository orchestrates a ListImages call followed by a DeleteImages
+// call to delete all images from the input ECR repository name.
+func (s Service) ClearRepository(repoName string) error {
+	images, err := s.ListImages(repoName)
+
+	if err != nil {
+		return err
+	}
+
+	// TODO: add retry handling in case images are added to a repository after a call to ListImages
+	return s.DeleteImages(images, repoName)
+}
+
 // URIFromARN converts an ECR Repo ARN to a Repository URI
 func URIFromARN(repositoryARN string) (string, error) {
 	repoARN, err := arn.Parse(repositoryARN)
diff --git a/internal/pkg/build/docker/docker.go b/internal/pkg/build/docker/docker.go
index 2c7a8691..c778f5b2 100644
--- a/internal/pkg/build/docker/docker.go
+++ b/internal/pkg/build/docker/docker.go
@@ -9,7 +9,7 @@ import (
 	"os/exec"
 	"strings"
 
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/log"
 )
 
diff --git a/internal/pkg/build/ecr/ecr.go b/internal/pkg/build/ecr/ecr.go
deleted file mode 100644
index 4ea3a294..00000000
--- a/internal/pkg/build/ecr/ecr.go
+++ /dev/null
@@ -1,78 +0,0 @@
-// 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
-}
-
-// 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 a3253937..8905da76 100644
--- a/internal/pkg/cli/app.go
+++ b/internal/pkg/cli/app.go
@@ -28,9 +28,13 @@ An application represents an Amazon ECS service or task.`,
 	cmd.AddCommand(BuildAppInitCmd())
 	cmd.AddCommand(BuildAppPackageCmd())
 	cmd.AddCommand(BuildAppDeployCommand())
+	cmd.AddCommand(BuildAppDeleteCmd())
+
 	cmd.SetUsageTemplate(template.Usage)
+
 	cmd.Annotations = map[string]string{
 		"group": group.Develop,
 	}
+
 	return cmd
 }
diff --git a/internal/pkg/cli/app_delete.go b/internal/pkg/cli/app_delete.go
new file mode 100644
index 00000000..059f7d8b
--- /dev/null
+++ b/internal/pkg/cli/app_delete.go
@@ -0,0 +1,307 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cli
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/session"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/store"
+	"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"
+	"github.com/spf13/cobra"
+)
+
+const (
+	appDeleteConfirmPrompt = "Are you sure you want to delete %s from project %s?"
+	appDeleteConfirmHelp   = "This will undeploy the app from all environments, delete the local workspace file, and remove ECR repositories."
+)
+
+var (
+	errAppDeleteCancelled = errors.New("app delete cancelled - no changes made")
+)
+
+// BuildAppDeleteCmd builds the command to delete application(s).
+func BuildAppDeleteCmd() *cobra.Command {
+	opts := &deleteAppOpts{
+		GlobalOpts: NewGlobalOpts(),
+		spinner:    termprogress.NewSpinner(),
+		prompter:   prompt.New(),
+	}
+
+	cmd := &cobra.Command{
+		Use:   "delete [name]",
+		Short: "Deletes an application from your project.",
+		Example: `
+  Delete the "test" application.
+  /code $ archer app delete test
+
+  Delete the "test" application without prompting.
+  /code $ archer app delete test --yes`,
+		PreRunE: runCmdE(func(cmd *cobra.Command, args []string) error {
+			if len(args) != 1 {
+				return errors.New("requires a single application name as argument")
+			}
+
+			opts.app = args[0]
+
+			if err := opts.init(); err != nil {
+				return err
+			}
+
+			if err := opts.sourceInputs(); err != nil {
+				return err
+			}
+
+			return opts.confirmDelete()
+		}),
+		RunE: runCmdE(func(cmd *cobra.Command, args []string) error {
+			return opts.deleteApp()
+		}),
+	}
+
+	cmd.Flags().BoolVar(&opts.skipConfirmation, yesFlag, false, yesFlagDescription)
+
+	return cmd
+}
+
+type deleteAppOpts struct {
+	*GlobalOpts
+	app              string
+	env              string
+	skipConfirmation bool
+
+	projectService   projectService
+	workspaceService archer.Workspace
+	spinner          progress
+	prompter         prompter
+
+	projectEnvironments []*archer.Environment
+}
+
+func (opts deleteAppOpts) confirmDelete() error {
+	if opts.skipConfirmation {
+		return nil
+	}
+
+	deleteConfirmed, err := opts.prompter.Confirm(
+		fmt.Sprintf(appDeleteConfirmPrompt, opts.app, opts.projectName),
+		appDeleteConfirmHelp)
+
+	if err != nil {
+		return fmt.Errorf("app delete confirmation prompt: %w", err)
+	}
+
+	if !deleteConfirmed {
+		return errAppDeleteCancelled
+	}
+
+	return nil
+}
+
+func (opts *deleteAppOpts) init() error {
+	projectService, err := store.New()
+	if err != nil {
+		return fmt.Errorf("create project service: %w", err)
+	}
+	opts.projectService = projectService
+
+	workspaceService, err := workspace.New()
+	if err != nil {
+		return fmt.Errorf("intialize workspace service: %w", err)
+	}
+	opts.workspaceService = workspaceService
+
+	return nil
+}
+
+func (opts *deleteAppOpts) sourceInputs() error {
+	if opts.ProjectName() == "" {
+		return errNoProjectInWorkspace
+	}
+
+	if err := opts.sourceProjectData(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (opts *deleteAppOpts) sourceProjectData() error {
+	if err := opts.sourceWorkspaceApplications(); err != nil {
+		return err
+	}
+
+	if err := opts.sourceProjectEnvironments(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (opts *deleteAppOpts) sourceWorkspaceApplications() error {
+	localApps, err := opts.workspaceService.Apps()
+
+	if err != nil {
+		return fmt.Errorf("get app names: %w", err)
+	}
+
+	if len(localApps) == 0 {
+		return errors.New("no applications found")
+	}
+
+	exists := false
+	for _, app := range localApps {
+		if opts.app == app.AppName() {
+			exists = true
+		}
+	}
+	if !exists {
+		return fmt.Errorf("input app %s not found", opts.app)
+	}
+
+	return nil
+}
+
+func (opts *deleteAppOpts) sourceProjectEnvironments() error {
+	envs, err := opts.projectService.ListEnvironments(opts.ProjectName())
+
+	if err != nil {
+		return fmt.Errorf("get environments: %w", err)
+	}
+
+	if len(envs) == 0 {
+		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 deleteAppOpts) deleteApp() error {
+	if err := opts.deleteStacks(); err != nil {
+		return err
+	}
+	if err := opts.emptyECRRepos(); err != nil {
+		return err
+	}
+	if err := opts.removeAppProjectResources(); err != nil {
+		return err
+	}
+	if err := opts.deleteSSMParam(); err != nil {
+		return err
+	}
+	if err := opts.deleteWorkspaceFile(); err != nil {
+		return err
+	}
+
+	log.Ssuccessf("removed app %s from project %s\n", opts.app, opts.projectName)
+
+	return nil
+}
+
+func (opts deleteAppOpts) deleteStacks() error {
+	for _, env := range opts.projectEnvironments {
+		sess, err := session.FromRole(env.ManagerRoleARN, env.Region)
+		if err != nil {
+			return err
+		}
+
+		cfClient := cloudformation.New(sess)
+
+		stackName := fmt.Sprintf("%s-%s-%s", opts.projectName, env.Name, opts.app)
+
+		// TODO: check if the stack exists first
+		opts.spinner.Start(fmt.Sprintf("deleting app %s from env %s", opts.app, env.Name))
+		if err := cfClient.DeleteStackAndWait(stackName); err != nil {
+			opts.spinner.Stop(log.Serrorf("deleting app %s from env %s", opts.app, env.Name))
+
+			return err
+		}
+		opts.spinner.Stop(log.Ssuccessf("deleted app %s from env %s", opts.app, env.Name))
+	}
+
+	return nil
+}
+
+func (opts deleteAppOpts) emptyECRRepos() error {
+	var uniqueRegions []string
+	for _, env := range opts.projectEnvironments {
+		if !contains(env.Region, uniqueRegions) {
+			uniqueRegions = append(uniqueRegions, env.Region)
+		}
+	}
+
+	// TODO: centralized ECR repo name
+	repoName := fmt.Sprintf("%s/%s", opts.projectName, opts.app)
+
+	for _, region := range uniqueRegions {
+		sess, err := session.DefaultWithRegion(region)
+		if err != nil {
+			return err
+		}
+
+		ecrService := ecr.New(sess)
+
+		if err := ecrService.ClearRepository(repoName); err != nil {
+
+			return err
+		}
+	}
+
+	return nil
+}
+
+func (opts deleteAppOpts) removeAppProjectResources() error {
+	proj, err := opts.projectService.GetProject(opts.projectName)
+	if err != nil {
+		return err
+	}
+
+	sess, err := session.Default()
+	if err != nil {
+		return err
+	}
+
+	// TODO: make this opts.toolsAccountCfClient...
+	cfClient := cloudformation.New(sess)
+
+	opts.spinner.Start(fmt.Sprintf("removing app %s resources from project %s", opts.app, opts.projectName))
+	if err := cfClient.RemoveAppFromProject(proj, opts.app); err != nil {
+		opts.spinner.Stop(log.Serrorf("removing app %s resources from project %s", opts.app, opts.projectName))
+
+		return err
+	}
+	opts.spinner.Stop(log.Ssuccessf("removed app %s resources from project %s", opts.app, opts.projectName))
+
+	return nil
+}
+
+func (opts deleteAppOpts) deleteSSMParam() error {
+	if err := opts.projectService.DeleteApplication(opts.projectName, opts.app); err != nil {
+		return fmt.Errorf("remove app %s from project %s: %w", opts.app, opts.projectName, err)
+	}
+
+	return nil
+}
+
+func (opts deleteAppOpts) deleteWorkspaceFile() error {
+	if err := opts.workspaceService.DeleteFile(opts.app); err != nil {
+		return fmt.Errorf("delete app file %s: %w", opts.app, err)
+	}
+
+	return nil
+}
diff --git a/internal/pkg/cli/app_deploy.go b/internal/pkg/cli/app_deploy.go
index 56cd4cda..bfc4271c 100644
--- a/internal/pkg/cli/app_deploy.go
+++ b/internal/pkg/cli/app_deploy.go
@@ -11,21 +11,20 @@ import (
 	"os/exec"
 	"strings"
 
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy/cloudformation/stack"
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/store"
-	"github.com/google/uuid"
-	"github.com/spf13/cobra"
-
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	"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/deploy/cloudformation/stack"
 	"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"
 	"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/workspace"
+	"github.com/google/uuid"
+	"github.com/spf13/cobra"
 )
 
 // BuildAppDeployCommand builds the `app deploy` subcommand.
diff --git a/internal/pkg/cli/mocks/mock_projectservice.go b/internal/pkg/cli/mocks/mock_projectservice.go
index a6ca19ba..350848d2 100644
--- a/internal/pkg/cli/mocks/mock_projectservice.go
+++ b/internal/pkg/cli/mocks/mock_projectservice.go
@@ -6,7 +6,7 @@ package mocks
 
 import (
 	archer "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
-	ecr "github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	ecr "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	gomock "github.com/golang/mock/gomock"
 	reflect "reflect"
 )
@@ -180,6 +180,20 @@ func (mr *MockprojectServiceMockRecorder) CreateApplication(app interface{}) *go
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApplication", reflect.TypeOf((*MockprojectService)(nil).CreateApplication), app)
 }
 
+// DeleteApplication mocks base method
+func (m *MockprojectService) DeleteApplication(projectName, appName string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteApplication", projectName, appName)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteApplication indicates an expected call of DeleteApplication
+func (mr *MockprojectServiceMockRecorder) DeleteApplication(projectName, appName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplication", reflect.TypeOf((*MockprojectService)(nil).DeleteApplication), projectName, appName)
+}
+
 // MockecrService is a mock of ecrService interface
 type MockecrService struct {
 	ctrl     *gomock.Controller
diff --git a/internal/pkg/deploy/cloudformation/project.go b/internal/pkg/deploy/cloudformation/project.go
index 49be5401..36cc4ed9 100644
--- a/internal/pkg/deploy/cloudformation/project.go
+++ b/internal/pkg/deploy/cloudformation/project.go
@@ -207,6 +207,46 @@ func (cf CloudFormation) AddAppToProject(project *archer.Project, appName string
 	return nil
 }
 
+// RemoveAppFromProject attempts to remove App specific resources (ECR repositories) from the Project resource stack.
+func (cf CloudFormation) RemoveAppFromProject(project *archer.Project, appName string) error {
+	projectConfig := stack.NewProjectStackConfig(&deploy.CreateProjectInput{
+		Project:   project.Name,
+		AccountID: project.AccountID,
+	}, cf.box)
+	previouslyDeployedConfig, err := cf.getLastDeployedProjectConfig(projectConfig)
+	if err != nil {
+		return fmt.Errorf("get previous project %s config: %w", project.Name, err)
+	}
+
+	// We'll generate a new list of Accounts to remove the account associated
+	// with the input app to be removed.
+	var appList []string
+	shouldRemoveApp := false
+	for _, app := range previouslyDeployedConfig.Apps {
+		if app == appName {
+			shouldRemoveApp = true
+			continue
+		}
+		appList = append(appList, app)
+	}
+
+	if !shouldRemoveApp {
+		return nil
+	}
+
+	newDeploymentConfig := stack.ProjectResourcesConfig{
+		Version:  previouslyDeployedConfig.Version + 1,
+		Apps:     appList,
+		Accounts: previouslyDeployedConfig.Accounts,
+		Project:  projectConfig.Project,
+	}
+	if err := cf.deployProjectConfig(projectConfig, &newDeploymentConfig); err != nil {
+		return fmt.Errorf("removing %s app resources from project: %w", appName, err)
+	}
+
+	return nil
+}
+
 // AddEnvToProject takes a new environment and updates the Project configuration
 // with new Account IDs in resource policies (KMS Keys and ECR Repos) - and
 // sets up a new stack instance if the environment is in a new region.
@@ -319,7 +359,6 @@ func (cf CloudFormation) deployProjectConfig(projectConfig *stack.ProjectStackCo
 	}
 
 	return cf.waitForStackSetOperation(projectConfig.StackSetName(), *output.OperationId)
-
 }
 
 // addNewStackInstances takes an environment and determines if we need to create a new
diff --git a/internal/pkg/deploy/cloudformation/stack.go b/internal/pkg/deploy/cloudformation/stack.go
new file mode 100644
index 00000000..8ff2c7f8
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/stack.go
@@ -0,0 +1,42 @@
+// 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/service/cloudformation"
+)
+
+// DeleteStack calls the CloudFormation DeleteStack API with the input stack name.
+func (cf CloudFormation) DeleteStack(stackName string) error {
+	if _, err := cf.client.DeleteStack(&cloudformation.DeleteStackInput{
+		StackName: aws.String(stackName),
+	}); err != nil {
+		return fmt.Errorf("delete stack %s: %w", stackName, err)
+	}
+
+	return nil
+}
+
+// WaitForStackDelete calls the CloudFormation WaitUntilStackDeleteComplete API with the input stack name.
+func (cf CloudFormation) WaitForStackDelete(stackName string) error {
+	if err := cf.client.WaitUntilStackDeleteComplete(&cloudformation.DescribeStacksInput{
+		StackName: aws.String(stackName),
+	}); err != nil {
+		return fmt.Errorf("wait until stack delete complete %s: %w", stackName, err)
+	}
+
+	return nil
+}
+
+// DeleteStackAndWait orchestrates a call to DeleteStack followed by a call to WaitForStackDelete.
+func (cf CloudFormation) DeleteStackAndWait(stackName string) error {
+	if err := cf.DeleteStack(stackName); err != nil {
+		return err
+	}
+
+	return cf.WaitForStackDelete(stackName)
+}
diff --git a/internal/pkg/store/app.go b/internal/pkg/store/app.go
index 9a3a0125..58da61f7 100644
--- a/internal/pkg/store/app.go
+++ b/internal/pkg/store/app.go
@@ -94,3 +94,23 @@ func (s *Store) ListApplications(projectName string) ([]*archer.Application, err
 	}
 	return applications, nil
 }
+
+// DeleteApplication removes an application from SSM.
+// If the application does not exist in the store or is successfully deleted then returns nil. Otherwise, returns an error.
+func (s *Store) DeleteApplication(projectName, appName string) error {
+	paramName := fmt.Sprintf(fmtAppParamPath, projectName, appName)
+	_, err := s.ssmClient.DeleteParameter(&ssm.DeleteParameterInput{
+		Name: aws.String(paramName),
+	})
+
+	if err != nil {
+		if aerr, ok := err.(awserr.Error); ok {
+			switch aerr.Code() {
+			case ssm.ErrCodeParameterNotFound:
+				return nil
+			}
+		}
+		return fmt.Errorf("delete application %s from project %s: %w", appName, projectName, err)
+	}
+	return nil
+}
diff --git a/internal/pkg/workspace/workspace.go b/internal/pkg/workspace/workspace.go
index b7ff0c28..d7e5fd45 100644
--- a/internal/pkg/workspace/workspace.go
+++ b/internal/pkg/workspace/workspace.go
@@ -25,7 +25,7 @@ import (
 	"gopkg.in/yaml.v3"
 
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"	
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"
 )
 
 const (
@@ -145,7 +145,7 @@ func (ws *Workspace) Apps() ([]archer.Manifest, error) {
 		if err != nil {
 			return nil, err
 		}
-		
+
 		mf, err := manifest.UnmarshalApp(manifestBytes)
 		if err != nil {
 			return nil, err
@@ -271,3 +271,25 @@ func (ws *Workspace) WriteFile(blob []byte, filename string) (string, error) {
 func (ws *Workspace) AppManifestFileName(appName string) string {
 	return fmt.Sprintf(fmtAppManifestFileName, appName)
 }
+
+// DeleteFile takes in a file name under the project directory (e.g. frontend-app.yml) and deletes it.
+func (ws *Workspace) DeleteFile(fileName string) error {
+	manifestDirPath, err := ws.manifestDirectoryPath()
+	if err != nil {
+		return err
+	}
+
+	manifestFileName := ws.AppManifestFileName(fileName)
+	manifestPath := filepath.Join(manifestDirPath, manifestFileName)
+	manifestFileExists, err := ws.fsUtils.Exists(manifestPath)
+
+	if err != nil {
+		return err
+	}
+
+	if !manifestFileExists {
+		return &ErrManifestNotFound{ManifestName: manifestFileName}
+	}
+
+	return ws.fsUtils.Remove(manifestPath)
+}
diff --git a/mocks/mock_app.go b/mocks/mock_app.go
index 00084c2c..2a6ad2ee 100644
--- a/mocks/mock_app.go
+++ b/mocks/mock_app.go
@@ -77,6 +77,20 @@ func (mr *MockApplicationStoreMockRecorder) CreateApplication(app interface{}) *
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApplication", reflect.TypeOf((*MockApplicationStore)(nil).CreateApplication), app)
 }
 
+// DeleteApplication mocks base method
+func (m *MockApplicationStore) DeleteApplication(projectName, appName string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteApplication", projectName, appName)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteApplication indicates an expected call of DeleteApplication
+func (mr *MockApplicationStoreMockRecorder) DeleteApplication(projectName, appName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplication", reflect.TypeOf((*MockApplicationStore)(nil).DeleteApplication), projectName, appName)
+}
+
 // MockApplicationLister is a mock of ApplicationLister interface
 type MockApplicationLister struct {
 	ctrl     *gomock.Controller
@@ -189,3 +203,40 @@ func (mr *MockApplicationCreatorMockRecorder) CreateApplication(app interface{})
 	mr.mock.ctrl.T.Helper()
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateApplication", reflect.TypeOf((*MockApplicationCreator)(nil).CreateApplication), app)
 }
+
+// MockApplicationDeleter is a mock of ApplicationDeleter interface
+type MockApplicationDeleter struct {
+	ctrl     *gomock.Controller
+	recorder *MockApplicationDeleterMockRecorder
+}
+
+// MockApplicationDeleterMockRecorder is the mock recorder for MockApplicationDeleter
+type MockApplicationDeleterMockRecorder struct {
+	mock *MockApplicationDeleter
+}
+
+// NewMockApplicationDeleter creates a new mock instance
+func NewMockApplicationDeleter(ctrl *gomock.Controller) *MockApplicationDeleter {
+	mock := &MockApplicationDeleter{ctrl: ctrl}
+	mock.recorder = &MockApplicationDeleterMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use
+func (m *MockApplicationDeleter) EXPECT() *MockApplicationDeleterMockRecorder {
+	return m.recorder
+}
+
+// DeleteApplication mocks base method
+func (m *MockApplicationDeleter) DeleteApplication(projectName, appName string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteApplication", projectName, appName)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteApplication indicates an expected call of DeleteApplication
+func (mr *MockApplicationDeleterMockRecorder) DeleteApplication(projectName, appName interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteApplication", reflect.TypeOf((*MockApplicationDeleter)(nil).DeleteApplication), projectName, appName)
+}
diff --git a/mocks/mock_workspace.go b/mocks/mock_workspace.go
index dd940f41..0154eb0b 100644
--- a/mocks/mock_workspace.go
+++ b/mocks/mock_workspace.go
@@ -92,6 +92,20 @@ func (mr *MockWorkspaceMockRecorder) AppManifestFileName(appName interface{}) *g
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppManifestFileName", reflect.TypeOf((*MockWorkspace)(nil).AppManifestFileName), appName)
 }
 
+// DeleteFile mocks base method
+func (m *MockWorkspace) DeleteFile(name string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteFile", name)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteFile indicates an expected call of DeleteFile
+func (mr *MockWorkspaceMockRecorder) DeleteFile(name interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFile", reflect.TypeOf((*MockWorkspace)(nil).DeleteFile), name)
+}
+
 // Create mocks base method
 func (m *MockWorkspace) Create(projectName string) error {
 	m.ctrl.T.Helper()
@@ -218,6 +232,20 @@ func (mr *MockManifestIOMockRecorder) AppManifestFileName(appName interface{}) *
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppManifestFileName", reflect.TypeOf((*MockManifestIO)(nil).AppManifestFileName), appName)
 }
 
+// DeleteFile mocks base method
+func (m *MockManifestIO) DeleteFile(name string) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteFile", name)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteFile indicates an expected call of DeleteFile
+func (mr *MockManifestIOMockRecorder) DeleteFile(name interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteFile", reflect.TypeOf((*MockManifestIO)(nil).DeleteFile), name)
+}
+
 // MockWorkspaceFileReadWriter is a mock of WorkspaceFileReadWriter interface
 type MockWorkspaceFileReadWriter struct {
 	ctrl     *gomock.Controller


Test patch:
diff --git a/internal/pkg/aws/ecr/erc_test.go b/internal/pkg/aws/ecr/erc_test.go
index 4661cb71..c722f860 100644
--- a/internal/pkg/aws/ecr/erc_test.go
+++ b/internal/pkg/aws/ecr/erc_test.go
@@ -4,12 +4,279 @@
 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
+
+	t *testing.T
+
+	mockGetAuthorizationToken func(*ecr.GetAuthorizationTokenInput) (*ecr.GetAuthorizationTokenOutput, error)
+	mockCreateRepository      func(*ecr.CreateRepositoryInput) (*ecr.CreateRepositoryOutput, error)
+	mockDescribeRepositories  func(*ecr.DescribeRepositoriesInput) (*ecr.DescribeRepositoriesOutput, error)
+	mockDescribeImages        func(*testing.T, *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error)
+	mockBatchDeleteImage      func(*testing.T, *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, 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 (m mockECR) DescribeImages(input *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) {
+	return m.mockDescribeImages(m.t, input)
+}
+
+func (m mockECR) BatchDeleteImage(input *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) {
+	return m.mockBatchDeleteImage(m.t, 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 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("ecr describe repository %s: %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)
+		})
+	}
+}
+
+func TestListImages(t *testing.T) {
+	mockRepoName := "mockRepoName"
+	mockError := errors.New("mockError")
+	mockDigest := "mockDigest"
+
+	tests := map[string]struct {
+		mockDescribeImages func(*testing.T, *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error)
+
+		wantImages []Image
+		wantError  error
+	}{
+		"should wrap error returned by ECR DescribeImages": {
+			mockDescribeImages: func(t *testing.T, in *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) {
+				return nil, mockError
+			},
+			wantImages: nil,
+			wantError:  fmt.Errorf("ecr repo %s describe images: %w", mockRepoName, mockError),
+		},
+		"should return Image list": {
+			mockDescribeImages: func(t *testing.T, in *ecr.DescribeImagesInput) (*ecr.DescribeImagesOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockRepoName, *in.RepositoryName)
+
+				return &ecr.DescribeImagesOutput{
+					ImageDetails: []*ecr.ImageDetail{
+						&ecr.ImageDetail{
+							ImageDigest: aws.String(mockDigest),
+						},
+					},
+				}, nil
+			},
+			wantImages: []Image{Image{Digest: mockDigest}},
+			wantError:  nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			s := Service{
+				ecr: mockECR{
+					t: t,
+
+					mockDescribeImages: test.mockDescribeImages,
+				},
+			}
+
+			gotImages, gotError := s.ListImages(mockRepoName)
+
+			require.ElementsMatch(t, test.wantImages, gotImages)
+			require.Equal(t, test.wantError, gotError)
+		})
+	}
+}
+
+func TestDeleteImages(t *testing.T) {
+	mockRepoName := "mockRepoName"
+	mockError := errors.New("mockError")
+	mockDigest := "mockDigest"
+	mockImages := []Image{
+		Image{
+			Digest: mockDigest,
+		},
+	}
+
+	tests := map[string]struct {
+		images []Image
+
+		mockBatchDeleteImage func(*testing.T, *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error)
+
+		want error
+	}{
+		"should return an error if input Image list is empty": {
+			images: nil,
+			want:   nil,
+		},
+		"should wrap error return from BatchDeleteImage": {
+			images: mockImages,
+			mockBatchDeleteImage: func(t *testing.T, in *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) {
+				return nil, mockError
+			},
+			want: fmt.Errorf("ecr repo %s batch delete image: %w", mockRepoName, mockError),
+		},
+		"should return nil if call to BatchDeleteImage successful": {
+			images: mockImages,
+			mockBatchDeleteImage: func(t *testing.T, in *ecr.BatchDeleteImageInput) (*ecr.BatchDeleteImageOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockRepoName, *in.RepositoryName)
+
+				var imageIdentifiers []*ecr.ImageIdentifier
+				for _, image := range mockImages {
+					imageIdentifiers = append(imageIdentifiers, image.imageIdentifier())
+				}
+				require.ElementsMatch(t, imageIdentifiers, in.ImageIds)
+
+				return &ecr.BatchDeleteImageOutput{}, nil
+			},
+			want: nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			s := Service{
+				ecr: mockECR{
+					t: t,
+
+					mockBatchDeleteImage: test.mockBatchDeleteImage,
+				},
+			}
+
+			got := s.DeleteImages(test.images, mockRepoName)
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
+
 func TestURIFromARN(t *testing.T) {
 
 	testCases := map[string]struct {
diff --git a/internal/pkg/build/docker/docker_test.go b/internal/pkg/build/docker/docker_test.go
index b51416fd..5ea15b64 100644
--- a/internal/pkg/build/docker/docker_test.go
+++ b/internal/pkg/build/docker/docker_test.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 	"testing"
 
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	"github.com/stretchr/testify/require"
 )
 
diff --git a/internal/pkg/build/ecr/ecr_test.go b/internal/pkg/build/ecr/ecr_test.go
deleted file mode 100644
index 96c62146..00000000
--- a/internal/pkg/build/ecr/ecr_test.go
+++ /dev/null
@@ -1,147 +0,0 @@
-// 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 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_delete_test.go b/internal/pkg/cli/app_delete_test.go
new file mode 100644
index 00000000..6d9772b9
--- /dev/null
+++ b/internal/pkg/cli/app_delete_test.go
@@ -0,0 +1,202 @@
+// 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/aws/amazon-ecs-cli-v2/mocks"
+
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
+	climocks "github.com/aws/amazon-ecs-cli-v2/internal/pkg/cli/mocks"
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+)
+
+func TestConfirmDelete(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockPrompter := climocks.NewMockprompter(ctrl)
+
+	mockAppName := "mockAppName"
+	mockProjectName := "mockProjectName"
+	mockError := errors.New("mockError")
+
+	tests := map[string]struct {
+		skipConfirmation bool
+
+		setupMocks func()
+
+		want error
+	}{
+		"should skip confirmation": {
+			skipConfirmation: true,
+			setupMocks:       func() {},
+			want:             nil,
+		},
+		"should wrap error returned from prompter confirmation": {
+			skipConfirmation: false,
+			setupMocks: func() {
+				mockPrompter.EXPECT().Confirm(
+					fmt.Sprintf(appDeleteConfirmPrompt, mockAppName, mockProjectName),
+					appDeleteConfirmHelp,
+				).Times(1).Return(true, mockError)
+			},
+			want: fmt.Errorf("app delete confirmation prompt: %w", mockError),
+		},
+		"should return error if user does not confirm app deletion": {
+			skipConfirmation: false,
+			setupMocks: func() {
+				mockPrompter.EXPECT().Confirm(
+					fmt.Sprintf(appDeleteConfirmPrompt, mockAppName, mockProjectName),
+					appDeleteConfirmHelp,
+				).Times(1).Return(false, nil)
+			},
+			want: errAppDeleteCancelled,
+		},
+		"should return error nil if user confirms app delete": {
+			skipConfirmation: false,
+			setupMocks: func() {
+				mockPrompter.EXPECT().Confirm(
+					fmt.Sprintf(appDeleteConfirmPrompt, mockAppName, mockProjectName),
+					appDeleteConfirmHelp,
+				).Times(1).Return(true, nil)
+			},
+			want: nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			test.setupMocks()
+			opts := deleteAppOpts{
+				GlobalOpts: &GlobalOpts{
+					projectName: mockProjectName,
+				},
+				app:              mockAppName,
+				skipConfirmation: test.skipConfirmation,
+				prompter:         mockPrompter,
+			}
+
+			got := opts.confirmDelete()
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
+
+func TestSourceInputs(t *testing.T) {
+	t.Run("should return errNoProjectInWorkspace", func(t *testing.T) {
+		opts := deleteAppOpts{
+			GlobalOpts: &GlobalOpts{
+				projectName: "",
+			},
+		}
+
+		got := opts.sourceInputs()
+
+		require.Equal(t, errNoProjectInWorkspace, got)
+	})
+}
+
+func TestSourceWorkspaceApplications(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockWorkspaceService := mocks.NewMockWorkspace(ctrl)
+	mockError := errors.New("mockError")
+
+	tests := map[string]struct {
+		setupMocks func()
+
+		want error
+	}{
+		"should wrap error returned from workspaceService Apps() call": {
+			setupMocks: func() {
+				mockWorkspaceService.EXPECT().Apps().Times(1).Return(nil, mockError)
+			},
+			want: fmt.Errorf("get app names: %w", mockError),
+		},
+		"should return error if call to Apps() returns empty list": {
+			setupMocks: func() {
+				mockWorkspaceService.EXPECT().Apps().Times(1).Return([]archer.Manifest{}, nil)
+			},
+			want: errors.New("no applications found"),
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			test.setupMocks()
+			opts := deleteAppOpts{
+				workspaceService: mockWorkspaceService,
+			}
+
+			got := opts.sourceWorkspaceApplications()
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
+
+func TestAppDeleteSourceProjectEnvironments(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+
+	mockProjectService := climocks.NewMockprojectService(ctrl)
+	mockProjectName := "mockProjectName"
+	mockError := errors.New("mockError")
+	mockEnvList := []*archer.Environment{
+		&archer.Environment{Project: mockProjectName},
+	}
+
+	tests := map[string]struct {
+		setupMocks func()
+
+		want            error
+		wantOptsEnvList []*archer.Environment
+	}{
+		"should wrap error returned from call to ListEnvironments()": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return(nil, mockError)
+			},
+			want:            fmt.Errorf("get environments: %w", mockError),
+			wantOptsEnvList: nil,
+		},
+		"should return error if call to ListEnvironments() returns an empty list": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return([]*archer.Environment{}, nil)
+			},
+			want:            errors.New("no environments found"),
+			wantOptsEnvList: nil,
+		},
+		"should set the opts environment list": {
+			setupMocks: func() {
+				mockProjectService.EXPECT().ListEnvironments(gomock.Eq(mockProjectName)).Times(1).Return(mockEnvList, nil)
+			},
+			want:            nil,
+			wantOptsEnvList: mockEnvList,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			test.setupMocks()
+			opts := deleteAppOpts{
+				GlobalOpts: &GlobalOpts{
+					projectName: mockProjectName,
+				},
+				projectService: mockProjectService,
+			}
+
+			got := opts.sourceProjectEnvironments()
+
+			require.Equal(t, test.want, got)
+			require.Equal(t, test.wantOptsEnvList, opts.projectEnvironments)
+		})
+	}
+}
diff --git a/internal/pkg/cli/app_deploy_test.go b/internal/pkg/cli/app_deploy_test.go
index 5b8fc798..afc8312a 100644
--- a/internal/pkg/cli/app_deploy_test.go
+++ b/internal/pkg/cli/app_deploy_test.go
@@ -12,7 +12,7 @@ import (
 	"github.com/stretchr/testify/require"
 
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer"
-	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/build/ecr"
+	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr"
 	climocks "github.com/aws/amazon-ecs-cli-v2/internal/pkg/cli/mocks"
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/manifest"
 	"github.com/aws/amazon-ecs-cli-v2/mocks"
diff --git a/internal/pkg/deploy/cloudformation/cloudformation_test.go b/internal/pkg/deploy/cloudformation/cloudformation_test.go
index daedfb4a..15afd49a 100644
--- a/internal/pkg/deploy/cloudformation/cloudformation_test.go
+++ b/internal/pkg/deploy/cloudformation/cloudformation_test.go
@@ -49,6 +49,7 @@ type mockCloudFormation struct {
 	mockWaitUntilChangeSetCreateCompleteWithContext func(t *testing.T, in *cloudformation.DescribeChangeSetInput) error
 	mockWaitUntilStackCreateCompleteWithContext     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
 	mockWaitUntilStackUpdateCompleteWithContext     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
+	mockWaitUntilStackDeleteComplete                func(t *testing.T, in *cloudformation.DescribeStacksInput) error
 	mockWaitUntilStackDeleteCompleteWithContext     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
 }
 
@@ -120,6 +121,10 @@ func (cf mockCloudFormation) WaitUntilStackCreateCompleteWithContext(context con
 	return cf.mockWaitUntilStackCreateCompleteWithContext(cf.t, in)
 }
 
+func (cf mockCloudFormation) WaitUntilStackDeleteComplete(in *cloudformation.DescribeStacksInput) error {
+	return cf.mockWaitUntilStackDeleteComplete(cf.t, in)
+}
+
 func (cf mockCloudFormation) WaitUntilStackDeleteCompleteWithContext(context context.Context, in *cloudformation.DescribeStacksInput, opts ...request.WaiterOption) error {
 	return cf.mockWaitUntilStackDeleteCompleteWithContext(cf.t, in)
 }
diff --git a/internal/pkg/deploy/cloudformation/project_test.go b/internal/pkg/deploy/cloudformation/project_test.go
index 8c040a18..c709e1c1 100644
--- a/internal/pkg/deploy/cloudformation/project_test.go
+++ b/internal/pkg/deploy/cloudformation/project_test.go
@@ -672,6 +672,77 @@ func TestAddAppToProject(t *testing.T) {
 	}
 }
 
+func TestRemoveAppFromProject(t *testing.T) {
+	mockProject := &archer.Project{
+		Name:      "testproject",
+		AccountID: "1234",
+	}
+
+	tests := map[string]struct {
+		app string
+
+		mockDescribeStackSet          func(t *testing.T, in *cloudformation.DescribeStackSetInput) (*cloudformation.DescribeStackSetOutput, error)
+		mockUpdateStackSet            func(t *testing.T, in *cloudformation.UpdateStackSetInput) (*cloudformation.UpdateStackSetOutput, error)
+		mockDescribeStackSetOperation func(t *testing.T, in *cloudformation.DescribeStackSetOperationInput) (*cloudformation.DescribeStackSetOperationOutput, error)
+
+		want error
+	}{
+		"should remove input app from the stack set": {
+			app: "test",
+			mockDescribeStackSet: func(t *testing.T, in *cloudformation.DescribeStackSetInput) (*cloudformation.DescribeStackSetOutput, error) {
+				body, err := yaml.Marshal(stack.DeployedProjectMetadata{Metadata: stack.ProjectResourcesConfig{
+					Apps:    []string{"test", "firsttest"},
+					Version: 1,
+				}})
+				require.NoError(t, err)
+				return &cloudformation.DescribeStackSetOutput{
+					StackSet: &cloudformation.StackSet{
+						TemplateBody: aws.String(string(body)),
+					},
+				}, nil
+			},
+			mockUpdateStackSet: func(t *testing.T, in *cloudformation.UpdateStackSetInput) (*cloudformation.UpdateStackSetOutput, error) {
+				require.NotZero(t, *in.TemplateBody, "TemplateBody should not be empty")
+				configToDeploy, err := stack.ProjectConfigFrom(in.TemplateBody)
+				require.NoError(t, err)
+				require.ElementsMatch(t, []string{"firsttest"}, configToDeploy.Apps)
+				require.Empty(t, configToDeploy.Accounts, "config account list should be empty")
+				require.Equal(t, 2, configToDeploy.Version)
+
+				return &cloudformation.UpdateStackSetOutput{
+					OperationId: aws.String("2"),
+				}, nil
+			},
+			mockDescribeStackSetOperation: func(t *testing.T, in *cloudformation.DescribeStackSetOperationInput) (*cloudformation.DescribeStackSetOperationOutput, error) {
+				return &cloudformation.DescribeStackSetOperationOutput{
+					StackSetOperation: &cloudformation.StackSetOperation{
+						Status: aws.String(cloudformation.StackSetOperationStatusSucceeded),
+					},
+				}, nil
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			cf := CloudFormation{
+				client: mockCloudFormation{
+					t: t,
+
+					mockDescribeStackSet:          test.mockDescribeStackSet,
+					mockUpdateStackSet:            test.mockUpdateStackSet,
+					mockDescribeStackSetOperation: test.mockDescribeStackSetOperation,
+				},
+				box: templates.Box(),
+			}
+
+			got := cf.RemoveAppFromProject(mockProject, test.app)
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
+
 func TestWaitForStackSetOperation(t *testing.T) {
 	waitingForOperation := true
 	testCases := map[string]struct {
diff --git a/internal/pkg/deploy/cloudformation/stack_test.go b/internal/pkg/deploy/cloudformation/stack_test.go
new file mode 100644
index 00000000..1236851b
--- /dev/null
+++ b/internal/pkg/deploy/cloudformation/stack_test.go
@@ -0,0 +1,101 @@
+// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+package cloudformation
+
+import (
+	"errors"
+	"fmt"
+	"testing"
+
+	"github.com/aws/aws-sdk-go/service/cloudformation"
+	"github.com/stretchr/testify/require"
+)
+
+func TestDeleteStack(t *testing.T) {
+	mockStackName := "mockStackName"
+	mockError := errors.New("mockError")
+
+	tests := map[string]struct {
+		mockDeleteStack func(*testing.T, *cloudformation.DeleteStackInput) (*cloudformation.DeleteStackOutput, error)
+
+		want error
+	}{
+		"should wrap error returned by DeleteStack": {
+			mockDeleteStack: func(t *testing.T, in *cloudformation.DeleteStackInput) (*cloudformation.DeleteStackOutput, error) {
+				return nil, mockError
+			},
+			want: fmt.Errorf("delete stack %s: %w", mockStackName, mockError),
+		},
+		"should return nil given success": {
+			mockDeleteStack: func(t *testing.T, in *cloudformation.DeleteStackInput) (*cloudformation.DeleteStackOutput, error) {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return &cloudformation.DeleteStackOutput{}, nil
+			},
+			want: nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			cf := CloudFormation{
+				client: mockCloudFormation{
+					t: t,
+
+					mockDeleteStack: test.mockDeleteStack,
+				},
+			}
+
+			got := cf.DeleteStack(mockStackName)
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
+
+func TestWaitForStackDelete(t *testing.T) {
+	mockStackName := "mockStackName"
+	mockError := errors.New("mockError")
+
+	tests := map[string]struct {
+		mockWaitUntilStackDeleteComplete func(t *testing.T, in *cloudformation.DescribeStacksInput) error
+
+		want error
+	}{
+		"should wrap error returned by DeleteStack": {
+			mockWaitUntilStackDeleteComplete: func(t *testing.T, in *cloudformation.DescribeStacksInput) error {
+				return mockError
+			},
+			want: fmt.Errorf("wait until stack delete complete %s: %w", mockStackName, mockError),
+		},
+		"should return nil given success": {
+			mockWaitUntilStackDeleteComplete: func(t *testing.T, in *cloudformation.DescribeStacksInput) error {
+				t.Helper()
+
+				require.Equal(t, mockStackName, *in.StackName)
+
+				return nil
+			},
+			want: nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			cf := CloudFormation{
+				client: mockCloudFormation{
+					t: t,
+
+					mockWaitUntilStackDeleteComplete: test.mockWaitUntilStackDeleteComplete,
+				},
+			}
+
+			got := cf.WaitForStackDelete(mockStackName)
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
diff --git a/internal/pkg/store/app_test.go b/internal/pkg/store/app_test.go
index a25c18d4..be6b6400 100644
--- a/internal/pkg/store/app_test.go
+++ b/internal/pkg/store/app_test.go
@@ -4,6 +4,7 @@
 package store
 
 import (
+	"errors"
 	"fmt"
 	"testing"
 
@@ -309,3 +310,60 @@ func TestStore_CreateApplication(t *testing.T) {
 		})
 	}
 }
+
+func TestDeleteApplication(t *testing.T) {
+	mockProjectName := "mockProjectName"
+	mockAppName := "mockAppName"
+	mockError := errors.New("mockError")
+
+	tests := map[string]struct {
+		inProjectName   string
+		inAppName       string
+		mockDeleteParam func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error)
+
+		want error
+	}{
+		"parameter is already deleted": {
+			inProjectName: mockProjectName,
+			inAppName:     mockAppName,
+			mockDeleteParam: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
+				return nil, awserr.New(ssm.ErrCodeParameterNotFound, "Not found", nil)
+			},
+		},
+		"unexpected error": {
+			inProjectName: mockProjectName,
+			inAppName:     mockAppName,
+			mockDeleteParam: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
+				return nil, mockError
+			},
+			want: fmt.Errorf("delete application %s from project %s: %w", mockAppName, mockProjectName, mockError),
+		},
+		"successfully deleted param": {
+			inProjectName: mockProjectName,
+			inAppName:     mockAppName,
+			mockDeleteParam: func(t *testing.T, in *ssm.DeleteParameterInput) (*ssm.DeleteParameterOutput, error) {
+				wantedPath := fmt.Sprintf(fmtAppParamPath, mockProjectName, mockAppName)
+
+				require.Equal(t, wantedPath, *in.Name)
+
+				return nil, nil
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			s := &Store{
+				ssmClient: &mockSSM{
+					t: t,
+
+					mockDeleteParameter: test.mockDeleteParam,
+				},
+			}
+
+			got := s.DeleteApplication(test.inProjectName, test.inAppName)
+
+			require.Equal(t, test.want, got)
+		})
+	}
+}
diff --git a/internal/pkg/workspace/workspace_test.go b/internal/pkg/workspace/workspace_test.go
index ed55bf23..6f1ff408 100644
--- a/internal/pkg/workspace/workspace_test.go
+++ b/internal/pkg/workspace/workspace_test.go
@@ -485,3 +485,63 @@ func TestCreate(t *testing.T) {
 		})
 	}
 }
+
+func TestDeleteFile(t *testing.T) {
+	tests := map[string]struct {
+		workingDir     string
+		manifestFile   string
+		want           error
+		mockFileSystem func(appFS afero.Fs)
+	}{
+		"should delete the file if it exists": {
+			manifestFile: "frontend",
+			workingDir:   "test/",
+			mockFileSystem: func(appFS afero.Fs) {
+				appFS.MkdirAll("test/ecs-project", 0755)
+				afero.WriteFile(appFS, "test/ecs-project/frontend-app.yml", []byte("frontend"), 0644)
+			},
+		},
+		"should return an ErrManifestNotFound if file to delete doesn't exist": {
+			manifestFile: "traveling-salesman",
+			want:         fmt.Errorf("manifest file traveling-salesman-app.yml does not exists"),
+			workingDir:   "test/",
+			mockFileSystem: func(appFS afero.Fs) {
+				appFS.MkdirAll("test/ecs-project", 0755)
+				afero.WriteFile(appFS, "test/ecs-project/frontend-app.yml", []byte("frontend"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/backend-app.yml", []byte("backend"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/not-manifest.yml", []byte("nothing"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/.ecs-workspace", []byte("hiddenproject"), 0644)
+			},
+		},
+		"should return an error if manifest directory cannot be found": {
+			manifestFile: "frontend",
+			want:         fmt.Errorf("couldn't find a directory called ecs-project up to 5 levels up from /"),
+			workingDir:   "/",
+			mockFileSystem: func(appFS afero.Fs) {
+				appFS.MkdirAll("test/ecs-project", 0755)
+				afero.WriteFile(appFS, "test/ecs-project/frontend-app.yml", []byte("frontend"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/backend-app.yml", []byte("backend"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/not-manifest.yml", []byte("nothing"), 0644)
+				afero.WriteFile(appFS, "test/ecs-project/.ecs-workspace", []byte("hiddenproject"), 0644)
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			appFS := afero.NewMemMapFs()
+			test.mockFileSystem(appFS)
+
+			ws := Workspace{
+				workingDir: test.workingDir,
+				fsUtils:    &afero.Afero{Fs: appFS},
+			}
+
+			got := ws.DeleteFile(test.manifestFile)
+
+			if test.want != nil {
+				require.Equal(t, test.want.Error(), got.Error())
+			}
+		})
+	}
+}