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

Issue:
Need double check when updating the pipeline if already exist
Currently, when running `archer pipeline update`, it either deploy a new pipeline or update the current pipeline. However, this can cause a problem for those users who accidentally run the command and do not mean to update their pipeline (since the `update` command contains both "create" and "update" functionalities). To fix this issue we need to add double check prompt to make sure users really want to update their pipeline. 

Pull request description:
Add double check for pipeline update if pipeline exists
<!-- Provide summary of changes -->
Fix #456.
<!-- Issue number, if available. E.g. "Fixes #31", "Addresses #42, 77" -->

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.


Patch:
diff --git a/internal/pkg/cli/deploy.go b/internal/pkg/cli/deploy.go
index 0853a54e..6da9f1a7 100644
--- a/internal/pkg/cli/deploy.go
+++ b/internal/pkg/cli/deploy.go
@@ -16,7 +16,9 @@ type environmentDeployer interface {
 }
 
 type pipelineDeployer interface {
-	DeployPipeline(env *deploy.CreatePipelineInput) error
+	CreatePipeline(env *deploy.CreatePipelineInput) error
+	UpdatePipeline(env *deploy.CreatePipelineInput) error
+	PipelineExists(env *deploy.CreatePipelineInput) (bool, error)
 	AddPipelineResourcesToProject(project *archer.Project, region string) error
 	projectResourcesGetter
 	// TODO: Add StreamPipelineCreation method
diff --git a/internal/pkg/cli/mocks/mock_deploy.go b/internal/pkg/cli/mocks/mock_deploy.go
index 412f7d7a..0d1533c3 100644
--- a/internal/pkg/cli/mocks/mock_deploy.go
+++ b/internal/pkg/cli/mocks/mock_deploy.go
@@ -100,18 +100,47 @@ func (m *MockpipelineDeployer) EXPECT() *MockpipelineDeployerMockRecorder {
 	return m.recorder
 }
 
-// DeployPipeline mocks base method
-func (m *MockpipelineDeployer) DeployPipeline(env *deploy.CreatePipelineInput) error {
+// CreatePipeline mocks base method
+func (m *MockpipelineDeployer) CreatePipeline(env *deploy.CreatePipelineInput) error {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "DeployPipeline", env)
+	ret := m.ctrl.Call(m, "CreatePipeline", env)
 	ret0, _ := ret[0].(error)
 	return ret0
 }
 
-// DeployPipeline indicates an expected call of DeployPipeline
-func (mr *MockpipelineDeployerMockRecorder) DeployPipeline(env interface{}) *gomock.Call {
+// CreatePipeline indicates an expected call of CreatePipeline
+func (mr *MockpipelineDeployerMockRecorder) CreatePipeline(env interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeployPipeline", reflect.TypeOf((*MockpipelineDeployer)(nil).DeployPipeline), env)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePipeline", reflect.TypeOf((*MockpipelineDeployer)(nil).CreatePipeline), env)
+}
+
+// UpdatePipeline mocks base method
+func (m *MockpipelineDeployer) UpdatePipeline(env *deploy.CreatePipelineInput) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdatePipeline", env)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdatePipeline indicates an expected call of UpdatePipeline
+func (mr *MockpipelineDeployerMockRecorder) UpdatePipeline(env interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePipeline", reflect.TypeOf((*MockpipelineDeployer)(nil).UpdatePipeline), env)
+}
+
+// PipelineExists mocks base method
+func (m *MockpipelineDeployer) PipelineExists(env *deploy.CreatePipelineInput) (bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PipelineExists", env)
+	ret0, _ := ret[0].(bool)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// PipelineExists indicates an expected call of PipelineExists
+func (mr *MockpipelineDeployerMockRecorder) PipelineExists(env interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipelineExists", reflect.TypeOf((*MockpipelineDeployer)(nil).PipelineExists), env)
 }
 
 // AddPipelineResourcesToProject mocks base method
@@ -412,18 +441,47 @@ func (mr *MockdeployerMockRecorder) DelegateDNSPermissions(project, accountID in
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelegateDNSPermissions", reflect.TypeOf((*Mockdeployer)(nil).DelegateDNSPermissions), project, accountID)
 }
 
-// DeployPipeline mocks base method
-func (m *Mockdeployer) DeployPipeline(env *deploy.CreatePipelineInput) error {
+// CreatePipeline mocks base method
+func (m *Mockdeployer) CreatePipeline(env *deploy.CreatePipelineInput) error {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "DeployPipeline", env)
+	ret := m.ctrl.Call(m, "CreatePipeline", env)
 	ret0, _ := ret[0].(error)
 	return ret0
 }
 
-// DeployPipeline indicates an expected call of DeployPipeline
-func (mr *MockdeployerMockRecorder) DeployPipeline(env interface{}) *gomock.Call {
+// CreatePipeline indicates an expected call of CreatePipeline
+func (mr *MockdeployerMockRecorder) CreatePipeline(env interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePipeline", reflect.TypeOf((*Mockdeployer)(nil).CreatePipeline), env)
+}
+
+// UpdatePipeline mocks base method
+func (m *Mockdeployer) UpdatePipeline(env *deploy.CreatePipelineInput) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdatePipeline", env)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdatePipeline indicates an expected call of UpdatePipeline
+func (mr *MockdeployerMockRecorder) UpdatePipeline(env interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdatePipeline", reflect.TypeOf((*Mockdeployer)(nil).UpdatePipeline), env)
+}
+
+// PipelineExists mocks base method
+func (m *Mockdeployer) PipelineExists(env *deploy.CreatePipelineInput) (bool, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "PipelineExists", env)
+	ret0, _ := ret[0].(bool)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// PipelineExists indicates an expected call of PipelineExists
+func (mr *MockdeployerMockRecorder) PipelineExists(env interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeployPipeline", reflect.TypeOf((*Mockdeployer)(nil).DeployPipeline), env)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PipelineExists", reflect.TypeOf((*Mockdeployer)(nil).PipelineExists), env)
 }
 
 // AddPipelineResourcesToProject mocks base method
diff --git a/internal/pkg/cli/pipeline_update.go b/internal/pkg/cli/pipeline_update.go
index d678e976..b3f1edb8 100644
--- a/internal/pkg/cli/pipeline_update.go
+++ b/internal/pkg/cli/pipeline_update.go
@@ -28,16 +28,24 @@ const (
 	fmtAddPipelineResourcesStart    = "Adding pipeline resources to your project: %s"
 	fmtAddPipelineResourcesComplete = "Successfully added pipeline resources to your project: %s"
 
+	fmtCreatePipelineFailed   = "Failed to create a new pipeline: %s."
+	fmtCreatePipelineStart    = "Creating a new pipeline: %s"
+	fmtCreatePipelineComplete = "Successfully created a new pipeline: %s"
+
 	fmtUpdatePipelineFailed   = "Failed to accept changes for pipeline: %s."
 	fmtUpdatePipelineStart    = "Proposing infrastructure changes for the pipeline: %s"
 	fmtUpdatePipelineComplete = "Successfully updated pipeline: %s"
+
+	fmtUpdateEnvPrompt = "Are you sure you want to update an existing pipeline: %s?"
 )
 
 var errNoPipelineFile = errors.New("there was no pipeline manifest found in your workspace. Please run `ecs-preview pipeline init` to create an pipeline")
 
 // UpdatePipelineOpts holds the configuration needed to create or update a pipeline
 type UpdatePipelineOpts struct {
-	PipelineFile string
+	PipelineFile     string
+	PipelineName     string
+	SkipConfirmation bool
 	// Deploy bool
 
 	pipelineDeployer pipelineDeployer
@@ -118,6 +126,53 @@ func (opts *UpdatePipelineOpts) getArtifactBuckets() ([]deploy.ArtifactBucket, e
 	return buckets, nil
 }
 
+func (opts *UpdatePipelineOpts) shouldUpdate() (bool, error) {
+	if opts.SkipConfirmation {
+		return true, nil
+	}
+
+	shouldUpdate, err := opts.prompt.Confirm(fmt.Sprintf(fmtUpdateEnvPrompt, opts.PipelineName), "")
+	if err != nil {
+		return false, fmt.Errorf("prompt for pipeline update: %w", err)
+	}
+	return shouldUpdate, nil
+}
+
+func (opts *UpdatePipelineOpts) deployPipeline(in *deploy.CreatePipelineInput) error {
+	exist, err := opts.pipelineDeployer.PipelineExists(in)
+	if err != nil {
+		return fmt.Errorf("check if pipeline exists: %w", err)
+	}
+	if !exist {
+		opts.prog.Start(fmt.Sprintf(fmtCreatePipelineStart, color.HighlightUserInput(opts.PipelineName)))
+		if err := opts.pipelineDeployer.CreatePipeline(in); err != nil {
+			var alreadyExists *cloudformation.ErrStackAlreadyExists
+			if !errors.As(err, &alreadyExists) {
+				opts.prog.Stop(log.Serrorf(fmtCreatePipelineFailed, color.HighlightUserInput(opts.PipelineName)))
+				return fmt.Errorf("create pipeline: %w", err)
+			}
+		}
+		opts.prog.Stop(log.Ssuccessf(fmtCreatePipelineComplete, color.HighlightUserInput(opts.PipelineName)))
+		return nil
+	}
+
+	// If the stack already exists - we update it
+	shouldUpdate, err := opts.shouldUpdate()
+	if err != nil {
+		return err
+	}
+	if !shouldUpdate {
+		return nil
+	}
+	opts.prog.Start(fmt.Sprintf(fmtUpdatePipelineStart, color.HighlightUserInput(opts.PipelineName)))
+	if err := opts.pipelineDeployer.UpdatePipeline(in); err != nil {
+		opts.prog.Stop(log.Serrorf(fmtUpdatePipelineFailed, color.HighlightUserInput(opts.PipelineName)))
+		return fmt.Errorf("update pipeline: %w", err)
+	}
+	opts.prog.Stop(log.Ssuccessf(fmtUpdatePipelineComplete, color.HighlightUserInput(opts.PipelineName)))
+	return nil
+}
+
 // Execute create a new pipeline or update the current pipeline if it already exists.
 func (opts *UpdatePipelineOpts) Execute() error {
 	// bootstrap pipeline resources
@@ -138,6 +193,7 @@ func (opts *UpdatePipelineOpts) Execute() error {
 	if err != nil {
 		return fmt.Errorf("unmarshal pipeline file %s: %w", workspace.PipelineFileName, err)
 	}
+	opts.PipelineName = pipeline.Name
 	source := &deploy.Source{
 		ProviderName: pipeline.Source.ProviderName,
 		Properties:   pipeline.Source.Properties,
@@ -163,17 +219,9 @@ func (opts *UpdatePipelineOpts) Execute() error {
 		ArtifactBuckets: artifactBuckets,
 	}
 
-	// deploy pipeline
-	opts.prog.Start(fmt.Sprintf(fmtUpdatePipelineStart, color.HighlightUserInput(opts.PipelineFile)))
-
-	if err := opts.pipelineDeployer.DeployPipeline(deployPipelineInput); err != nil {
-		// TODO if pipeline already exists, still update?
-		opts.prog.Stop(log.Serrorf(fmtUpdatePipelineFailed, color.HighlightUserInput(opts.PipelineFile)))
-		return fmt.Errorf("deploy pipeline: %w", err)
+	if err := opts.deployPipeline(deployPipelineInput); err != nil {
+		return err
 	}
-	// TODO stream events
-
-	opts.prog.Stop(log.Ssuccessf(fmtUpdatePipelineComplete, color.HighlightUserInput(opts.PipelineFile))) // change to pipeline name?
 
 	return nil
 }
@@ -224,6 +272,7 @@ func BuildPipelineUpdateCmd() *cobra.Command {
 		}),
 	}
 	cmd.Flags().StringVarP(&opts.PipelineFile, pipelineFileFlag, pipelineFileFlagShort, workspace.PipelineFileName, pipelineFileFlagDescription)
+	cmd.Flags().BoolVar(&opts.SkipConfirmation, yesFlag, false, yesFlagDescription)
 	// cmd.Flags().BoolVar(&opts.Deploy, deployFlag, false, deployFlagDescription)
 
 	return cmd
diff --git a/internal/pkg/deploy/cloudformation/pipeline.go b/internal/pkg/deploy/cloudformation/pipeline.go
index 368c75b1..30acbcb8 100644
--- a/internal/pkg/deploy/cloudformation/pipeline.go
+++ b/internal/pkg/deploy/cloudformation/pipeline.go
@@ -16,11 +16,23 @@ import (
 	"github.com/aws/aws-sdk-go/service/cloudformation"
 )
 
-// DeployPipeline sets up a CodePipeline for deploying applications.
-// Project-level regional resources (such as KMS keys for de/encrypting &
-// S3 buckets for storing pipeline artifacts) should be provisioned using
-// `AddPipelineResourcesToProject()` before calling this function.
-func (cf CloudFormation) DeployPipeline(in *deploy.CreatePipelineInput) error {
+// PipelineExists checks if the pipeline with the provided config exists.
+func (cf CloudFormation) PipelineExists(in *deploy.CreatePipelineInput) (bool, error) {
+	stackConfig := stack.NewPipelineStackConfig(in)
+	describeStackInput := &cloudformation.DescribeStacksInput{StackName: aws.String(stackConfig.StackName())}
+	_, err := cf.describeStack(describeStackInput)
+	if err != nil {
+		var stackNotFound *ErrStackNotFound
+		if !errors.As(err, &stackNotFound) {
+			return false, err
+		}
+		return false, nil
+	}
+	return true, nil
+}
+
+// CreatePipeline sets up a new CodePipeline for deploying applications.
+func (cf CloudFormation) CreatePipeline(in *deploy.CreatePipelineInput) error {
 	pipelineConfig := stack.NewPipelineStackConfig(in)
 
 	// First attempt to create the pipeline stack
@@ -32,13 +44,12 @@ func (cf CloudFormation) DeployPipeline(in *deploy.CreatePipelineInput) error {
 		}
 		return nil
 	}
+	return err
+}
 
-	// If the stack already exists - we update it
-	var alreadyExists *ErrStackAlreadyExists
-	if !errors.As(err, &alreadyExists) {
-		return err
-	}
-
+// UpdatePipeline updates an existing CodePipeline for deploying applications.
+func (cf CloudFormation) UpdatePipeline(in *deploy.CreatePipelineInput) error {
+	pipelineConfig := stack.NewPipelineStackConfig(in)
 	if err := cf.update(pipelineConfig); err != nil {
 		if err == errChangeSetEmpty {
 			// If there are no updates, then exit successfully.


Test patch:
diff --git a/internal/pkg/cli/pipeline_update_test.go b/internal/pkg/cli/pipeline_update_test.go
index d30671ae..9c910f4a 100644
--- a/internal/pkg/cli/pipeline_update_test.go
+++ b/internal/pkg/cli/pipeline_update_test.go
@@ -169,6 +169,7 @@ func TestUpdatePipelineOpts_Execute(t *testing.T) {
 		region       = "us-west-2"
 		accountID    = "123456789012"
 		pipelineFile = "pipeline.yml"
+		pipelineName = "pipepiper"
 		content      = `
 name: pipepiper
 version: 1
@@ -225,12 +226,14 @@ stages:
 	testCases := map[string]struct {
 		inProject      *archer.Project
 		inProjectName  string
+		inPipelineName string
 		inRegion       string
 		inPipelineFile string
 		mockDeployer   func(m *climocks.MockpipelineDeployer)
 		mockWorkspace  func(m *archermocks.MockWorkspace)
 		mockEnvStore   func(m *archermocks.MockEnvironmentStore)
 		mockProgress   func(m *climocks.Mockprogress)
+		mockPrompt     func(m *climocks.Mockprompter)
 		expectedError  error
 	}{
 		"create and deploy pipeline": {
@@ -250,17 +253,120 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(1)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(1)
+				m.EXPECT().Start(fmt.Sprintf(fmtCreatePipelineStart, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtCreatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtCreatePipelineComplete, pipelineName)).Times(1)
+				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineName)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
-				m.EXPECT().DeployPipeline(gomock.Any()).Return(nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(false, nil)
+				m.EXPECT().CreatePipeline(gomock.Any()).Return(nil)
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: nil,
 		},
+		"update and deploy pipeline": {
+			inProject:      &project,
+			inProjectName:  projectName,
+			inRegion:       region,
+			inPipelineFile: pipelineFile,
+			mockWorkspace: func(m *archermocks.MockWorkspace) {
+				m.EXPECT().ReadFile(gomock.Any()).Return([]byte(content), nil)
+				m.EXPECT().Apps().Return(manifest, nil).Times(1)
+			},
+			mockEnvStore: func(m *archermocks.MockEnvironmentStore) {
+				m.EXPECT().GetEnvironment(projectName, "chicken").Return(mockEnv, nil).Times(1)
+				m.EXPECT().GetEnvironment(projectName, "wings").Return(mockEnv, nil).Times(1)
+			},
+			mockProgress: func(m *climocks.Mockprogress) {
+				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
+				m.EXPECT().Start(fmt.Sprintf(fmtCreatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtCreatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtCreatePipelineComplete, pipelineName)).Times(0)
+				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineName)).Times(1)
+			},
+			mockDeployer: func(m *climocks.MockpipelineDeployer) {
+				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
+				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(true, nil)
+				m.EXPECT().UpdatePipeline(gomock.Any()).Return(nil)
+			},
+			mockPrompt: func(m *climocks.Mockprompter) {
+				m.EXPECT().Confirm(fmt.Sprintf(fmtUpdateEnvPrompt, pipelineName), "").Return(true, nil)
+			},
+			expectedError: nil,
+		},
+		"do not deploy pipeline if decline to update an existing pipeline": {
+			inProject:      &project,
+			inProjectName:  projectName,
+			inRegion:       region,
+			inPipelineFile: pipelineFile,
+			mockWorkspace: func(m *archermocks.MockWorkspace) {
+				m.EXPECT().ReadFile(gomock.Any()).Return([]byte(content), nil)
+				m.EXPECT().Apps().Return(manifest, nil).Times(1)
+			},
+			mockEnvStore: func(m *archermocks.MockEnvironmentStore) {
+				m.EXPECT().GetEnvironment(projectName, "chicken").Return(mockEnv, nil).Times(1)
+				m.EXPECT().GetEnvironment(projectName, "wings").Return(mockEnv, nil).Times(1)
+			},
+			mockProgress: func(m *climocks.Mockprogress) {
+				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
+				m.EXPECT().Start(fmt.Sprintf(fmtCreatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtCreatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtCreatePipelineComplete, pipelineName)).Times(0)
+				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineName)).Times(0)
+			},
+			mockDeployer: func(m *climocks.MockpipelineDeployer) {
+				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
+				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(true, nil)
+				m.EXPECT().UpdatePipeline(gomock.Any()).Return(nil).Times(0)
+			},
+			mockPrompt: func(m *climocks.Mockprompter) {
+				m.EXPECT().Confirm(fmt.Sprintf(fmtUpdateEnvPrompt, pipelineName), "").Return(false, nil)
+			},
+			expectedError: nil,
+		},
+		"returns an error if fails to prompt for pipeline update": {
+			inProject:      &project,
+			inProjectName:  projectName,
+			inRegion:       region,
+			inPipelineFile: pipelineFile,
+			mockWorkspace: func(m *archermocks.MockWorkspace) {
+				m.EXPECT().ReadFile(gomock.Any()).Return([]byte(content), nil)
+				m.EXPECT().Apps().Return(manifest, nil).Times(1)
+			},
+			mockEnvStore: func(m *archermocks.MockEnvironmentStore) {
+				m.EXPECT().GetEnvironment(projectName, "chicken").Return(mockEnv, nil).Times(1)
+				m.EXPECT().GetEnvironment(projectName, "wings").Return(mockEnv, nil).Times(1)
+			},
+			mockProgress: func(m *climocks.Mockprogress) {
+				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
+			},
+			mockDeployer: func(m *climocks.MockpipelineDeployer) {
+				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
+				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(true, nil)
+			},
+			mockPrompt: func(m *climocks.Mockprompter) {
+				m.EXPECT().Confirm(fmt.Sprintf(fmtUpdateEnvPrompt, pipelineName), "").Return(false, errors.New("some error"))
+			},
+			expectedError: fmt.Errorf("prompt for pipeline update: some error"),
+		},
 		"returns an error if fail to add pipeline resources to project": {
 			inProject:      &project,
 			inRegion:       region,
@@ -272,13 +378,11 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(1)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(0)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(errors.New("some error"))
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: fmt.Errorf("add pipeline resources to project %s in %s: some error", projectName, region),
 		},
 		"returns an error if fail to read pipeline file": {
@@ -294,13 +398,11 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: fmt.Errorf("read pipeline file %s: some error", pipelineFile),
 		},
 		"returns an error if unable to unmarshal pipeline file": {
@@ -317,13 +419,11 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: fmt.Errorf("unmarshal pipeline file %s: pipeline.yml contains invalid schema version: 0", pipelineFile),
 		},
 		"returns an error if unable to convert environments to deployment stage": {
@@ -340,13 +440,11 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: fmt.Errorf("convert environments to deployment stage: some error"),
 		},
 		"returns an error if fails to get cross-regional resources": {
@@ -366,17 +464,41 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(0)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, errors.New("some error"))
 			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
 			expectedError: fmt.Errorf("get cross-regional resources: some error"),
 		},
-		"returns an error if fails to deploy pipeline": {
+		"returns an error if fails to check if pipeline exists": {
+			inProject:      &project,
+			inRegion:       region,
+			inProjectName:  projectName,
+			inPipelineFile: pipelineFile,
+			mockWorkspace: func(m *archermocks.MockWorkspace) {
+				m.EXPECT().ReadFile(gomock.Any()).Return([]byte(content), nil)
+				m.EXPECT().Apps().Return(manifest, nil).Times(1)
+			},
+			mockEnvStore: func(m *archermocks.MockEnvironmentStore) {
+				m.EXPECT().GetEnvironment(projectName, "chicken").Return(mockEnv, nil).Times(1)
+				m.EXPECT().GetEnvironment(projectName, "wings").Return(mockEnv, nil).Times(1)
+			},
+			mockProgress: func(m *climocks.Mockprogress) {
+				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
+			},
+			mockDeployer: func(m *climocks.MockpipelineDeployer) {
+				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
+				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(false, errors.New("some error"))
+			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
+			expectedError: fmt.Errorf("check if pipeline exists: some error"),
+		},
+		"returns an error if fails to create pipeline": {
 			inProject:      &project,
 			inRegion:       region,
 			inProjectName:  projectName,
@@ -393,16 +515,56 @@ stages:
 				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
 				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
 				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
-				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineFile)).Times(1)
-				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineFile)).Times(1)
-				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineFile)).Times(0)
+				m.EXPECT().Start(fmt.Sprintf(fmtCreatePipelineStart, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtCreatePipelineFailed, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Ssuccessf(fmtCreatePipelineComplete, pipelineName)).Times(0)
+				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineName)).Times(0)
 			},
 			mockDeployer: func(m *climocks.MockpipelineDeployer) {
 				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
 				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
-				m.EXPECT().DeployPipeline(gomock.Any()).Return(errors.New("some error"))
+				m.EXPECT().PipelineExists(gomock.Any()).Return(false, nil)
+				m.EXPECT().CreatePipeline(gomock.Any()).Return(errors.New("some error"))
+			},
+			mockPrompt:    func(m *climocks.Mockprompter) {},
+			expectedError: fmt.Errorf("create pipeline: some error"),
+		},
+		"returns an error if fails to update pipeline": {
+			inProject:      &project,
+			inRegion:       region,
+			inProjectName:  projectName,
+			inPipelineFile: pipelineFile,
+			mockWorkspace: func(m *archermocks.MockWorkspace) {
+				m.EXPECT().ReadFile(gomock.Any()).Return([]byte(content), nil)
+				m.EXPECT().Apps().Return(manifest, nil).Times(1)
+			},
+			mockEnvStore: func(m *archermocks.MockEnvironmentStore) {
+				m.EXPECT().GetEnvironment(projectName, "chicken").Return(mockEnv, nil).Times(1)
+				m.EXPECT().GetEnvironment(projectName, "wings").Return(mockEnv, nil).Times(1)
+			},
+			mockProgress: func(m *climocks.Mockprogress) {
+				m.EXPECT().Start(fmt.Sprintf(fmtAddPipelineResourcesStart, projectName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtAddPipelineResourcesFailed, projectName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtAddPipelineResourcesComplete, projectName)).Times(1)
+				m.EXPECT().Start(fmt.Sprintf(fmtCreatePipelineStart, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Serrorf(fmtCreatePipelineFailed, pipelineName)).Times(0)
+				m.EXPECT().Stop(log.Ssuccessf(fmtCreatePipelineComplete, pipelineName)).Times(0)
+				m.EXPECT().Start(fmt.Sprintf(fmtUpdatePipelineStart, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Serrorf(fmtUpdatePipelineFailed, pipelineName)).Times(1)
+				m.EXPECT().Stop(log.Ssuccessf(fmtUpdatePipelineComplete, pipelineName)).Times(0)
+			},
+			mockDeployer: func(m *climocks.MockpipelineDeployer) {
+				m.EXPECT().AddPipelineResourcesToProject(&project, region).Return(nil)
+				m.EXPECT().GetRegionalProjectResources(gomock.Any()).Return(mockResources, nil)
+				m.EXPECT().PipelineExists(gomock.Any()).Return(true, nil)
+				m.EXPECT().UpdatePipeline(gomock.Any()).Return(errors.New("some error"))
+			},
+			mockPrompt: func(m *climocks.Mockprompter) {
+				m.EXPECT().Confirm(fmt.Sprintf(fmtUpdateEnvPrompt, pipelineName), "").Return(true, nil)
 			},
-			expectedError: fmt.Errorf("deploy pipeline: some error"),
+			expectedError: fmt.Errorf("update pipeline: some error"),
 		},
 	}
 	for name, tc := range testCases {
@@ -415,13 +577,16 @@ stages:
 			mockEnvStore := archermocks.NewMockEnvironmentStore(ctrl)
 			mockWorkspace := archermocks.NewMockWorkspace(ctrl)
 			mockProgress := climocks.NewMockprogress(ctrl)
+			mockPrompt := climocks.NewMockprompter(ctrl)
 			tc.mockDeployer(mockPipelineDeployer)
 			tc.mockEnvStore(mockEnvStore)
 			tc.mockWorkspace(mockWorkspace)
 			tc.mockProgress(mockProgress)
+			tc.mockPrompt(mockPrompt)
 
 			opts := &UpdatePipelineOpts{
 				PipelineFile:     tc.inPipelineFile,
+				PipelineName:     tc.inPipelineName,
 				pipelineDeployer: mockPipelineDeployer,
 				ws:               mockWorkspace,
 				project:          tc.inProject,
@@ -430,6 +595,7 @@ stages:
 				prog:             mockProgress,
 				GlobalOpts: &GlobalOpts{
 					projectName: tc.inProjectName,
+					prompt:      mockPrompt,
 				},
 			}
 
diff --git a/internal/pkg/deploy/cloudformation/pipeline_test.go b/internal/pkg/deploy/cloudformation/pipeline_test.go
index 0ce57db4..e1513fff 100644
--- a/internal/pkg/deploy/cloudformation/pipeline_test.go
+++ b/internal/pkg/deploy/cloudformation/pipeline_test.go
@@ -8,13 +8,14 @@ import (
 	"testing"
 
 	"github.com/aws/amazon-ecs-cli-v2/internal/pkg/deploy"
+	"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/aws-sdk-go/aws"
 	"github.com/aws/aws-sdk-go/service/cloudformation"
 	"github.com/stretchr/testify/require"
 )
 
-func TestCloudFormation_DeployPipeline(t *testing.T) {
+func TestCloudFormation_UpdatePipeline(t *testing.T) {
 	testCases := map[string]struct {
 		in *deploy.CreatePipelineInput
 
@@ -91,7 +92,110 @@ func TestCloudFormation_DeployPipeline(t *testing.T) {
 			}
 
 			// WHEN
-			err := cf.DeployPipeline(tc.in)
+			err := cf.UpdatePipeline(tc.in)
+
+			// THEN
+			if tc.wantedError != nil {
+				require.EqualError(t, err, tc.wantedError.Error())
+			} else {
+				require.Nil(t, err)
+			}
+		})
+	}
+}
+
+func TestCloudFormation_CreatePipeline(t *testing.T) {
+	input := &deploy.CreatePipelineInput{
+		ProjectName: "phonetool",
+		Name:        "phonetool-pipeline",
+		Source: &deploy.Source{
+			ProviderName: manifest.GithubProviderName,
+			Properties: map[string]interface{}{
+				manifest.GithubSecretIdKeyName: "my secret",
+				"repository":                   "github.com/hello/phonetool",
+			},
+		},
+		Stages:          nil,
+		ArtifactBuckets: nil,
+	}
+	testCases := map[string]struct {
+		in *deploy.CreatePipelineInput
+
+		mockCreateChangeSet                             func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error)
+		mockDescribeStacks                              func(t *testing.T, in *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error)
+		mockWaitUntilChangeSetCreateCompleteWithContext func(t *testing.T, in *cloudformation.DescribeChangeSetInput) error
+		mockDescribeChangeSet                           func(t *testing.T, in *cloudformation.DescribeChangeSetInput) (*cloudformation.DescribeChangeSetOutput, error)
+		mockExecuteChangeSet                            func(t *testing.T, in *cloudformation.ExecuteChangeSetInput) (*cloudformation.ExecuteChangeSetOutput, error)
+		mockWaitUntilStackCreateCompleteWithContext     func(t *testing.T, in *cloudformation.DescribeStacksInput) error
+		wantedError                                     error
+	}{
+		"successfully create a pipeline if a stack does not exist": {
+			in: input,
+			mockDescribeStacks: func(t *testing.T, in *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+				// Stack does not exist.
+				pipelineConfig := stack.NewPipelineStackConfig(input)
+				return nil, &ErrStackNotFound{stackName: pipelineConfig.StackName()}
+			},
+			mockCreateChangeSet: func(t *testing.T, in *cloudformation.CreateChangeSetInput) (*cloudformation.CreateChangeSetOutput, error) {
+				return &cloudformation.CreateChangeSetOutput{
+					Id:      aws.String("1234"),
+					StackId: aws.String("phonetool-1234"),
+				}, nil
+			},
+			mockDescribeChangeSet: func(t *testing.T, in *cloudformation.DescribeChangeSetInput) (*cloudformation.DescribeChangeSetOutput, error) {
+				return &cloudformation.DescribeChangeSetOutput{
+					Changes: []*cloudformation.Change{
+						&cloudformation.Change{},
+					},
+					ExecutionStatus: aws.String(cloudformation.ExecutionStatusAvailable),
+					StatusReason:    aws.String(""),
+				}, nil
+			},
+			mockWaitUntilChangeSetCreateCompleteWithContext: func(t *testing.T, in *cloudformation.DescribeChangeSetInput) error {
+				return nil
+			},
+			mockExecuteChangeSet: func(t *testing.T, in *cloudformation.ExecuteChangeSetInput) (*cloudformation.ExecuteChangeSetOutput, error) {
+				return &cloudformation.ExecuteChangeSetOutput{}, nil
+			},
+			mockWaitUntilStackCreateCompleteWithContext: func(t *testing.T, in *cloudformation.DescribeStacksInput) error {
+				return nil
+			},
+			wantedError: &ErrStackNotFound{stackName: "phonetool-phonetool-pipeline"},
+		},
+		"return stack exists error if a stack exists": {
+			in: input,
+			mockDescribeStacks: func(t *testing.T, in *cloudformation.DescribeStacksInput) (*cloudformation.DescribeStacksOutput, error) {
+				// Stack already exists.
+				return &cloudformation.DescribeStacksOutput{
+					Stacks: []*cloudformation.Stack{
+						{
+							StackStatus: aws.String(cloudformation.StackStatusCreateComplete),
+						},
+					},
+				}, nil
+			},
+			wantedError: errors.New("stack phonetool-phonetool-pipeline already exists"),
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			// GIVEN
+			cf := CloudFormation{
+				client: &mockCloudFormation{
+					t:                   t,
+					mockDescribeStacks:  tc.mockDescribeStacks,
+					mockCreateChangeSet: tc.mockCreateChangeSet,
+					mockWaitUntilChangeSetCreateCompleteWithContext: tc.mockWaitUntilChangeSetCreateCompleteWithContext,
+					mockDescribeChangeSet:                           tc.mockDescribeChangeSet,
+					mockExecuteChangeSet:                            tc.mockExecuteChangeSet,
+					mockWaitUntilStackCreateCompleteWithContext:     tc.mockWaitUntilStackCreateCompleteWithContext,
+				},
+				box: boxWithTemplateFile(),
+			}
+
+			// WHEN
+			err := cf.CreatePipeline(tc.in)
 
 			// THEN
 			if tc.wantedError != nil {