diff --git a/internal/pkg/cli/pipeline_package.go b/internal/pkg/cli/pipeline_package.go new file mode 100644 index 00000000000..d1b035a92e2 --- /dev/null +++ b/internal/pkg/cli/pipeline_package.go @@ -0,0 +1,224 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "path/filepath" + + "github.com/aws/copilot-cli/internal/pkg/cli/list" + "github.com/aws/copilot-cli/internal/pkg/config" + "github.com/aws/copilot-cli/internal/pkg/deploy" + "github.com/aws/copilot-cli/internal/pkg/manifest" +) + +type packagePipelineVars struct { + name string + appName string +} + +type packagePipelineOpts struct { + packagePipelineVars + + pipelineDeployer pipelineDeployer + tmplWriter io.WriteCloser + ws wsPipelineReader + codestar codestar + store store + pipelineStackConfig func(in *deploy.CreatePipelineInput) pipelineStackConfig + configureDeployedPipelineLister func() deployedPipelineLister + newSvcListCmd func(io.Writer, string) cmd + newJobListCmd func(io.Writer, string) cmd + + //catched variables + pipelineMft *manifest.Pipeline + app *config.Application + svcBuffer *bytes.Buffer + jobBuffer *bytes.Buffer +} + +func (o *packagePipelineOpts) Execute() error { + pipelines, err := o.ws.ListPipelines() + if err != nil { + return fmt.Errorf("list all pipelines in the workspace: %w", err) + } + + var pipelinePath string + for _, pipeline := range pipelines { + if pipeline.Name == o.name { + pipelinePath = pipeline.Path + break + } + } + if pipelinePath == "" { + return fmt.Errorf("pipeline %q not found", o.name) + } + + pipelineMft, err := o.getPipelineMft(pipelinePath) + if err != nil { + return err + } + + connection, ok := pipelineMft.Source.Properties["connection_name"] + if ok { + arn, err := o.codestar.GetConnectionARN((connection).(string)) + if err != nil { + return fmt.Errorf("get connection ARN: %w", err) + } + pipelineMft.Source.Properties["connection_arn"] = arn + } + + source, _, err := deploy.PipelineSourceFromManifest(pipelineMft.Source) + if err != nil { + return fmt.Errorf("read source from manifest: %w", err) + } + + relPath, err := o.ws.Rel(pipelinePath) + if err != nil { + return fmt.Errorf("convert manifest path to relative path: %w", err) + } + + stages, err := o.convertStages(pipelineMft.Stages) + if err != nil { + return fmt.Errorf("convert environments to deployment stage: %w", err) + } + + appConfig, err := o.store.GetApplication(o.appName) + if err != nil { + return fmt.Errorf("get application %s configuration: %w", o.appName, err) + } + o.app = appConfig + + artifactBuckets, err := o.getArtifactBuckets() + if err != nil { + return fmt.Errorf("get cross-regional resources: %w", err) + } + + isLegacy, err := o.isLegacy(pipelineMft.Name) + if err != nil { + return err + } + + var build deploy.Build + if err = build.Init(pipelineMft.Build, filepath.Dir(relPath)); err != nil { + return err + } + + deployPipelineInput := &deploy.CreatePipelineInput{ + AppName: o.appName, + Name: o.name, + IsLegacy: isLegacy, + Source: source, + Build: &build, + Stages: stages, + ArtifactBuckets: artifactBuckets, + AdditionalTags: o.app.Tags, + PermissionsBoundary: o.app.PermissionsBoundary, + } + + tpl, err := o.pipelineStackConfig(deployPipelineInput).Template() + if err != nil { + return fmt.Errorf("generate stack template: %w", err) + } + if _, err := o.tmplWriter.Write([]byte(tpl)); err != nil { + return err + } + o.tmplWriter.Close() + return nil +} + +func (o *packagePipelineOpts) getPipelineMft(pipelinePath string) (*manifest.Pipeline, error) { + if o.pipelineMft != nil { + return o.pipelineMft, nil + } + + pipelineMft, err := o.ws.ReadPipelineManifest(pipelinePath) + if err != nil { + return nil, fmt.Errorf("read pipeline manifest: %w", err) + } + + if err := pipelineMft.Validate(); err != nil { + return nil, fmt.Errorf("validate pipeline manifest: %w", err) + } + o.pipelineMft = pipelineMft + return pipelineMft, nil +} + +func (o *packagePipelineOpts) isLegacy(inputName string) (bool, error) { + lister := o.configureDeployedPipelineLister() + pipelines, err := lister.ListDeployedPipelines(o.appName) + if err != nil { + return false, fmt.Errorf("list deployed pipelines for app %s: %w", o.appName, err) + } + for _, pipeline := range pipelines { + if pipeline.ResourceName == inputName { + return pipeline.IsLegacy, nil + } + } + return false, nil +} + +func (o *packagePipelineOpts) convertStages(manifestStages []manifest.PipelineStage) ([]deploy.PipelineStage, error) { + var stages []deploy.PipelineStage + workloads, err := o.getLocalWorkloads() + if err != nil { + return nil, err + } + for _, stage := range manifestStages { + env, err := o.store.GetEnvironment(o.appName, stage.Name) + if err != nil { + return nil, fmt.Errorf("get environment %s in application %s: %w", stage.Name, o.appName, err) + } + + var stg deploy.PipelineStage + stg.Init(env, &stage, workloads) + stages = append(stages, stg) + } + return stages, nil +} + +func (o packagePipelineOpts) getLocalWorkloads() ([]string, error) { + var localWklds []string + if err := o.newSvcListCmd(o.svcBuffer, o.appName).Execute(); err != nil { + return nil, fmt.Errorf("get local services: %w", err) + } + if err := o.newJobListCmd(o.jobBuffer, o.appName).Execute(); err != nil { + return nil, fmt.Errorf("get local jobs: %w", err) + } + svcOutput, jobOutput := &list.ServiceJSONOutput{}, &list.JobJSONOutput{} + if err := json.Unmarshal(o.svcBuffer.Bytes(), svcOutput); err != nil { + return nil, fmt.Errorf("unmarshal service list output; %w", err) + } + for _, svc := range svcOutput.Services { + localWklds = append(localWklds, svc.Name) + } + if err := json.Unmarshal(o.jobBuffer.Bytes(), jobOutput); err != nil { + return nil, fmt.Errorf("unmarshal job list output; %w", err) + } + for _, job := range jobOutput.Jobs { + localWklds = append(localWklds, job.Name) + } + return localWklds, nil +} + +func (o *packagePipelineOpts) getArtifactBuckets() ([]deploy.ArtifactBucket, error) { + regionalResources, err := o.pipelineDeployer.GetRegionalAppResources(o.app) + if err != nil { + return nil, err + } + + var buckets []deploy.ArtifactBucket + for _, resource := range regionalResources { + bucket := deploy.ArtifactBucket{ + BucketName: resource.S3Bucket, + KeyArn: resource.KMSKeyARN, + } + buckets = append(buckets, bucket) + } + + return buckets, nil +} diff --git a/internal/pkg/cli/pipeline_package_test.go b/internal/pkg/cli/pipeline_package_test.go new file mode 100644 index 00000000000..f876fe2925a --- /dev/null +++ b/internal/pkg/cli/pipeline_package_test.go @@ -0,0 +1,308 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "errors" + "fmt" + "io" + "testing" + + "github.com/aws/copilot-cli/internal/pkg/cli/mocks" + "github.com/aws/copilot-cli/internal/pkg/config" + "github.com/aws/copilot-cli/internal/pkg/deploy" + "github.com/aws/copilot-cli/internal/pkg/deploy/cloudformation/stack" + "github.com/aws/copilot-cli/internal/pkg/manifest" + "github.com/aws/copilot-cli/internal/pkg/workspace" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +type packagePipelineMocks struct { + store *mocks.Mockstore + prompt *mocks.Mockprompter + prog *mocks.Mockprogress + deployer *mocks.MockpipelineDeployer + pipelineStackConfig *mocks.MockpipelineStackConfig + ws *mocks.MockwsPipelineReader + actionCmd *mocks.MockactionCommand + deployedPipelineLister *mocks.MockdeployedPipelineLister +} + +func TestPipelinePackageOpts_Execute(t *testing.T) { + const ( + appName = "badgoose" + region = "us-west-2" + accountID = "123456789012" + pipelineName = "pipepiper" + badPipelineName = "pipeline-badgoose-honkpipes" + pipelineManifestPath = "someStuff/someMoreStuff/aws-copilot-sample-service/copilot/pipelines/pipepiper/manifest.yml" + relativePath = "/copilot/pipelines/pipepiper/manifest.yml" + ) + pipeline := workspace.PipelineManifest{ + Name: pipelineName, + Path: pipelineManifestPath, + } + mockPipelineManifest := &manifest.Pipeline{ + Name: "pipepiper", + Version: 1, + Source: &manifest.Source{ + ProviderName: "GitHub", + Properties: map[string]interface{}{ + "repository": "aws/somethingCool", + "branch": "main", + }, + }, + Stages: []manifest.PipelineStage{ + { + Name: "chicken", + TestCommands: []string{"make test", "echo 'made test'"}, + }, + { + Name: "wings", + TestCommands: []string{"echo 'bok bok bok'"}, + }, + }, + } + + mockResources := []*stack.AppRegionalResources{ + { + S3Bucket: "someBucket", + KMSKeyARN: "someKey", + }, + } + mockEnv := &config.Environment{ + Name: "test", + App: appName, + Region: region, + AccountID: accountID, + } + + var someError = errors.New("some error") + + testCases := map[string]struct { + callMocks func(m packagePipelineMocks) + expectedError error + }{ + + "returns an error if fail to get the list of pipelines": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return(nil, someError), + ) + }, + expectedError: fmt.Errorf("list all pipelines in the workspace: some error"), + }, + "returns an error if fail to read pipeline file": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, someError), + ) + }, + expectedError: fmt.Errorf("read pipeline manifest: some error"), + }, + "returns an error if unable to unmarshal pipeline file": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(nil, someError), + ) + }, + expectedError: fmt.Errorf("read pipeline manifest: some error"), + }, + "returns an error if pipeline name fails validation": { + callMocks: func(m packagePipelineMocks) { + mockBadPipelineManifest := &manifest.Pipeline{ + Name: "12345678101234567820123456783012345678401234567850123456786012345678701234567880123456789012345671001", + Version: 1, + Source: &manifest.Source{ + ProviderName: "GitHub", + Properties: map[string]interface{}{ + "repository": "aws/somethingCool", + "branch": "main", + }, + }, + } + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockBadPipelineManifest, nil), + ) + }, + expectedError: fmt.Errorf("validate pipeline manifest: pipeline name '12345678101234567820123456783012345678401234567850123456786012345678701234567880123456789012345671001' must be shorter than 100 characters"), + }, + "returns an error if provider is not a supported type": { + callMocks: func(m packagePipelineMocks) { + mockBadPipelineManifest := &manifest.Pipeline{ + Name: badPipelineName, + Version: 1, + Source: &manifest.Source{ + ProviderName: "NotGitHub", + Properties: map[string]interface{}{ + "access_token_secret": "github-token-badgoose-backend", + "repository": "aws/somethingCool", + "branch": "main", + }, + }, + } + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockBadPipelineManifest, nil), + ) + }, + expectedError: fmt.Errorf("read source from manifest: invalid repo source provider: NotGitHub"), + }, + "returns an error while converting manifest path to relative path from workspace root": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, nil), + m.ws.EXPECT().Rel(pipelineManifestPath).Return("", someError), + ) + }, + expectedError: fmt.Errorf("convert manifest path to relative path: some error"), + }, + "returns an error if unable to convert environments to deployment stage": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, nil), + m.ws.EXPECT().Rel(pipelineManifestPath).Return(relativePath, nil), + m.actionCmd.EXPECT().Execute().Return(someError), + ) + }, + expectedError: fmt.Errorf("convert environments to deployment stage: get local services: some error"), + }, + "returns an error if fails to fetch an application": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, nil), + m.ws.EXPECT().Rel(pipelineManifestPath).Return(relativePath, nil), + m.actionCmd.EXPECT().Execute().Times(2), + + // convertStages + m.store.EXPECT().GetEnvironment(appName, "chicken").Return(mockEnv, nil).Times(1), + m.store.EXPECT().GetEnvironment(appName, "wings").Return(mockEnv, nil).Times(1), + + m.store.EXPECT().GetApplication(appName).Return(nil, someError), + ) + }, + expectedError: fmt.Errorf("get application %v configuration: some error", appName), + }, + "returns an error if fails to get cross-regional resources": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, nil), + m.ws.EXPECT().Rel(pipelineManifestPath).Return(relativePath, nil), + m.actionCmd.EXPECT().Execute().Times(2), + + // convertStages + m.store.EXPECT().GetEnvironment(appName, "chicken").Return(mockEnv, nil).Times(1), + m.store.EXPECT().GetEnvironment(appName, "wings").Return(mockEnv, nil).Times(1), + + m.store.EXPECT().GetApplication(appName).Return(&config.Application{ + Name: appName, + }, nil), + + // getArtifactBuckets + m.deployer.EXPECT().GetRegionalAppResources(gomock.Any()).Return(mockResources, someError), + ) + }, + expectedError: fmt.Errorf("get cross-regional resources: some error"), + }, + "error if failed to generate the template": { + callMocks: func(m packagePipelineMocks) { + gomock.InOrder( + m.ws.EXPECT().ListPipelines().Return([]workspace.PipelineManifest{pipeline}, nil), + m.ws.EXPECT().ReadPipelineManifest(pipelineManifestPath).Return(mockPipelineManifest, nil), + m.ws.EXPECT().Rel(pipelineManifestPath).Return(relativePath, nil), + m.actionCmd.EXPECT().Execute().Times(2), + + // convertStages + m.store.EXPECT().GetEnvironment(appName, "chicken").Return(mockEnv, nil).Times(1), + m.store.EXPECT().GetEnvironment(appName, "wings").Return(mockEnv, nil).Times(1), + + m.store.EXPECT().GetApplication(appName).Return(&config.Application{ + Name: appName, + }, nil), + + // getArtifactBuckets + m.deployer.EXPECT().GetRegionalAppResources(gomock.Any()).Return(mockResources, nil), + + // check if the pipeline has been deployed using a legacy naming. + m.deployedPipelineLister.EXPECT().ListDeployedPipelines(appName).Return([]deploy.Pipeline{}, nil), + m.pipelineStackConfig.EXPECT().Template().Return("", someError), + ) + + }, + expectedError: fmt.Errorf("generate stack template: some error"), + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockPipelineDeployer := mocks.NewMockpipelineDeployer(ctrl) + mockStore := mocks.NewMockstore(ctrl) + mockWorkspace := mocks.NewMockwsPipelineReader(ctrl) + mockProgress := mocks.NewMockprogress(ctrl) + mockPrompt := mocks.NewMockprompter(ctrl) + mockActionCmd := mocks.NewMockactionCommand(ctrl) + mockPipelineStackConfig := mocks.NewMockpipelineStackConfig(ctrl) + + mocks := packagePipelineMocks{ + store: mockStore, + prompt: mockPrompt, + prog: mockProgress, + deployer: mockPipelineDeployer, + ws: mockWorkspace, + actionCmd: mockActionCmd, + pipelineStackConfig: mockPipelineStackConfig, + deployedPipelineLister: mocks.NewMockdeployedPipelineLister(ctrl), + } + + tc.callMocks(mocks) + + opts := &packagePipelineOpts{ + packagePipelineVars: packagePipelineVars{ + appName: appName, + name: pipelineName, + }, + pipelineDeployer: mockPipelineDeployer, + pipelineStackConfig: func(in *deploy.CreatePipelineInput) pipelineStackConfig { + return mockPipelineStackConfig + }, + ws: mockWorkspace, + store: mockStore, + newSvcListCmd: func(w io.Writer, app string) cmd { + return mockActionCmd + }, + newJobListCmd: func(w io.Writer, app string) cmd { + return mockActionCmd + }, + configureDeployedPipelineLister: func() deployedPipelineLister { + return mocks.deployedPipelineLister + }, + svcBuffer: bytes.NewBufferString(`{"services":[{"app":"badgoose","name":"frontend","type":""}]}`), + jobBuffer: bytes.NewBufferString(`{"jobs":[{"app":"badgoose","name":"backend","type":""}]}`), + } + + // WHEN + err := opts.Execute() + + // THEN + if tc.expectedError != nil { + require.Error(t, err) + require.Equal(t, tc.expectedError.Error(), err.Error()) + } else { + require.NoError(t, err) + } + }) + } +}