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:
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 {