diff --git a/internal/pkg/cli/app_show.go b/internal/pkg/cli/app_show.go index db1abbc24ba..d687cce7a1c 100644 --- a/internal/pkg/cli/app_show.go +++ b/internal/pkg/cli/app_show.go @@ -4,9 +4,8 @@ package cli import ( + "context" "fmt" - "io" - "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ssm" "github.com/aws/copilot-cli/internal/pkg/aws/identity" @@ -18,6 +17,11 @@ import ( "github.com/aws/copilot-cli/internal/pkg/deploy" "github.com/aws/copilot-cli/internal/pkg/term/prompt" "github.com/aws/copilot-cli/internal/pkg/term/selector" + "golang.org/x/sync/errgroup" + "io" + "sort" + "sync" + "time" "github.com/aws/copilot-cli/internal/pkg/describe" "github.com/aws/copilot-cli/internal/pkg/term/log" @@ -27,6 +31,7 @@ import ( const ( appShowNamePrompt = "Which application would you like to show?" appShowNameHelpPrompt = "An application is a collection of related services." + waitForStackTimeout = 30 * time.Second ) type showAppVars struct { @@ -40,22 +45,29 @@ type showAppOpts struct { store store w io.Writer sel appSelector + deployStore deployedEnvironmentLister codepipeline pipelineGetter pipelineLister deployedPipelineLister newVersionGetter func(string) (versionGetter, error) } func newShowAppOpts(vars showAppVars) (*showAppOpts, error) { - defaultSession, err := sessions.ImmutableProvider(sessions.UserAgentExtras("app show")).Default() + sessProvider := sessions.ImmutableProvider(sessions.UserAgentExtras("app show")) + defaultSession, err := sessProvider.Default() if err != nil { return nil, fmt.Errorf("default session: %w", err) } store := config.NewSSMStore(identity.New(defaultSession), ssm.New(defaultSession), aws.StringValue(defaultSession.Config.Region)) + deployStore, err := deploy.NewStore(sessProvider, store) + if err != nil { + return nil, fmt.Errorf("connect to deploy store: %w", err) + } return &showAppOpts{ showAppVars: vars, store: store, w: log.OutputWriter, sel: selector.NewSelect(prompt.New(), store), + deployStore: deployStore, codepipeline: codepipeline.New(defaultSession), pipelineLister: deploy.NewPipelineStore(vars.name, rg.New(defaultSession)), newVersionGetter: func(s string) (versionGetter, error) { @@ -106,6 +118,19 @@ func (o *showAppOpts) Execute() error { fmt.Fprint(o.w, data) return nil } +func (o *showAppOpts) populateDeployedWorkloads(listWorkloads func(app, env string) ([]string, error), deployedEnvsFor map[string][]string, env string, lock sync.Locker) error { + deployedworkload, err := listWorkloads(o.name, env) + if err != nil { + return fmt.Errorf("list services/jobs deployed to %s: %w", env, err) + } + + lock.Lock() + defer lock.Unlock() + for _, wkld := range deployedworkload { + deployedEnvsFor[wkld] = append(deployedEnvsFor[wkld], env) + } + return nil +} func (o *showAppOpts) description() (*describe.App, error) { app, err := o.store.GetApplication(o.name) @@ -124,6 +149,27 @@ func (o *showAppOpts) description() (*describe.App, error) { if err != nil { return nil, fmt.Errorf("list jobs in application %s: %w", o.name, err) } + wkldDeployedtoEnvs := make(map[string][]string) + ctx, cancelWait := context.WithTimeout(context.Background(), waitForStackTimeout) + defer cancelWait() + g, _ := errgroup.WithContext(ctx) + var mux sync.Mutex + for i := range envs { + env := envs[i] + g.Go(func() error { + return o.populateDeployedWorkloads(o.deployStore.ListDeployedJobs, wkldDeployedtoEnvs, env.Name, &mux) + }) + g.Go(func() error { + return o.populateDeployedWorkloads(o.deployStore.ListDeployedServices, wkldDeployedtoEnvs, env.Name, &mux) + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + // Sort the map values so that `output` is consistent and the unit test won't be flaky. + for k := range wkldDeployedtoEnvs { + sort.Strings(wkldDeployedtoEnvs[k]) + } pipelines, err := o.pipelineLister.ListDeployedPipelines() if err != nil { @@ -170,13 +216,14 @@ func (o *showAppOpts) description() (*describe.App, error) { return nil, fmt.Errorf("get version for application %s: %w", o.name, err) } return &describe.App{ - Name: app.Name, - Version: version, - URI: app.Domain, - Envs: trimmedEnvs, - Services: trimmedSvcs, - Jobs: trimmedJobs, - Pipelines: pipelineInfo, + Name: app.Name, + Version: version, + URI: app.Domain, + Envs: trimmedEnvs, + Services: trimmedSvcs, + Jobs: trimmedJobs, + Pipelines: pipelineInfo, + WkldDeployedtoEnvs: wkldDeployedtoEnvs, }, nil } diff --git a/internal/pkg/cli/app_show_test.go b/internal/pkg/cli/app_show_test.go index e3cd4c46a6e..cea556b9d7f 100644 --- a/internal/pkg/cli/app_show_test.go +++ b/internal/pkg/cli/app_show_test.go @@ -20,6 +20,7 @@ import ( type showAppMocks struct { storeSvc *mocks.Mockstore sel *mocks.MockappSelector + deployStore *mocks.MockdeployedEnvironmentLister pipelineGetter *mocks.MockpipelineGetter pipelineLister *mocks.MockpipelineLister versionGetter *mocks.MockversionGetter @@ -215,6 +216,10 @@ func TestShowAppOpts_Execute(t *testing.T) { Prod: true, }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{"my-svc"}, nil).AnyTimes() m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{mockPipeline, mockLegacyPipeline}, nil) m.pipelineGetter.EXPECT(). GetPipeline("pipeline-my-app-my-pipeline-repo").Return(&codepipeline.Pipeline{ @@ -259,6 +264,10 @@ func TestShowAppOpts_Execute(t *testing.T) { Region: "us-west-1", }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{}, nil) m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{mockPipeline, mockLegacyPipeline}, nil) m.pipelineGetter.EXPECT(). GetPipeline("pipeline-my-app-my-pipeline-repo").Return(&codepipeline.Pipeline{ @@ -286,10 +295,10 @@ Environments Workloads - Name Type - ---- ---- - my-svc lb-web-svc - my-job Scheduled Job + Name Type Environments + ---- ---- ------------ + my-svc lb-web-svc test + my-job Scheduled Job prod, test Pipelines @@ -329,6 +338,10 @@ Pipelines Region: "us-west-1", }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{"my-svc"}, nil) m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{}, nil) m.versionGetter.EXPECT().Version().Return(deploy.LatestAppTemplateVersion, nil) }, @@ -348,15 +361,176 @@ Environments Workloads - Name Type - ---- ---- - my-svc lb-web-svc - my-job Scheduled Job + Name Type Environments + ---- ---- ------------ + my-svc lb-web-svc prod, test + my-job Scheduled Job prod, test Pipelines Name ---- +`, + }, + "when service/job is not deployed": { + setupMocks: func(m showAppMocks) { + m.storeSvc.EXPECT().GetApplication("my-app").Return(&config.Application{ + Name: "my-app", + Domain: "example.com", + }, nil) + m.storeSvc.EXPECT().ListServices("my-app").Return([]*config.Workload{ + { + Name: "my-svc", + Type: "lb-web-svc", + }, + }, nil) + m.storeSvc.EXPECT().ListJobs("my-app").Return([]*config.Workload{ + { + Name: "my-job", + Type: "Scheduled Job", + }, + }, nil) + m.storeSvc.EXPECT().ListEnvironments("my-app").Return([]*config.Environment{ + { + Name: "test", + Region: "us-west-2", + AccountID: "123456789", + }, + { + Name: "prod", + AccountID: "123456789", + Region: "us-west-1", + }, + }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{}, nil) + m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{mockPipeline}, nil) + m.pipelineGetter.EXPECT(). + GetPipeline("pipeline-my-app-my-pipeline-repo").Return(&codepipeline.Pipeline{ + Name: "my-pipeline-repo", + }, nil) + m.versionGetter.EXPECT().Version().Return(deploy.LatestAppTemplateVersion, nil) + }, + + wantedContent: `About + + Name my-app + Version v1.0.2 + URI example.com + +Environments + + Name AccountID Region + ---- --------- ------ + test 123456789 us-west-2 + prod 123456789 us-west-1 + +Workloads + + Name Type Environments + ---- ---- ------------ + my-svc lb-web-svc - + my-job Scheduled Job - + +Pipelines + + Name + ---- + my-pipeline-repo +`, + }, "when multiple services/jobs are deployed": { + setupMocks: func(m showAppMocks) { + m.storeSvc.EXPECT().GetApplication("my-app").Return(&config.Application{ + Name: "my-app", + Domain: "example.com", + }, nil) + m.storeSvc.EXPECT().ListServices("my-app").Return([]*config.Workload{ + { + Name: "my-svc", + Type: "lb-web-svc", + }, + }, nil) + m.storeSvc.EXPECT().ListJobs("my-app").Return([]*config.Workload{ + { + Name: "my-job", + Type: "Scheduled Job", + }, + }, nil) + m.storeSvc.EXPECT().ListEnvironments("my-app").Return([]*config.Environment{ + { + Name: "test1", + Region: "us-west-2", + AccountID: "123456789", + }, + { + Name: "prod1", + AccountID: "123456789", + Region: "us-west-1", + }, + { + Name: "test2", + Region: "us-west-2", + AccountID: "123456789", + }, + { + Name: "prod2", + AccountID: "123456789", + Region: "us-west-1", + }, + { + Name: "staging", + AccountID: "123456789", + Region: "us-west-1", + }, + }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test1").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod1").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod2").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test2").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "staging").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test1").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod1").Return([]string{}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod2").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test2").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "staging").Return([]string{"my-svc"}, nil) + m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{mockPipeline}, nil) + m.pipelineGetter.EXPECT(). + GetPipeline("pipeline-my-app-my-pipeline-repo").Return(&codepipeline.Pipeline{ + Name: "my-pipeline-repo", + }, nil) + m.versionGetter.EXPECT().Version().Return(deploy.LatestAppTemplateVersion, nil) + }, + + wantedContent: `About + + Name my-app + Version v1.0.2 + URI example.com + +Environments + + Name AccountID Region + ---- --------- ------ + test1 123456789 us-west-2 + prod1 123456789 us-west-1 + test2 123456789 us-west-2 + prod2 123456789 us-west-1 + staging 123456789 us-west-1 + +Workloads + + Name Type Environments + ---- ---- ------------ + my-svc lb-web-svc prod2, staging, test2 + my-job Scheduled Job prod1, staging, test1 + +Pipelines + + Name + ---- + my-pipeline-repo `, }, "returns error if fail to get application": { @@ -467,6 +641,10 @@ Pipelines Type: "Scheduled Job", }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{"my-svc"}, nil) m.pipelineLister.EXPECT().ListDeployedPipelines().Return(nil, testError) }, wantedError: fmt.Errorf("list pipelines in application %s: %w", "my-app", testError), @@ -503,6 +681,10 @@ Pipelines Type: "Scheduled Job", }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil) + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{"my-svc"}, nil) m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{mockPipeline}, nil) m.pipelineGetter.EXPECT(). GetPipeline("pipeline-my-app-my-pipeline-repo").Return(nil, testError) @@ -541,6 +723,10 @@ Pipelines Type: "Scheduled Job", }, }, nil) + m.deployStore.EXPECT().ListDeployedJobs("my-app", "test").Return([]string{"my-job"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedJobs("my-app", "prod").Return([]string{"my-job"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedServices("my-app", "test").Return([]string{"my-svc"}, nil).AnyTimes() + m.deployStore.EXPECT().ListDeployedServices("my-app", "prod").Return([]string{"my-svc"}, nil).AnyTimes() m.pipelineLister.EXPECT().ListDeployedPipelines().Return([]deploy.Pipeline{}, nil) m.versionGetter.EXPECT().Version().Return("", testError) }, @@ -558,12 +744,14 @@ Pipelines mockPLSvc := mocks.NewMockpipelineGetter(ctrl) mockVersionGetter := mocks.NewMockversionGetter(ctrl) mockPipelineLister := mocks.NewMockpipelineLister(ctrl) + mockDeployStore := mocks.NewMockdeployedEnvironmentLister(ctrl) mocks := showAppMocks{ storeSvc: mockStoreReader, pipelineGetter: mockPLSvc, versionGetter: mockVersionGetter, pipelineLister: mockPipelineLister, + deployStore: mockDeployStore, } tc.setupMocks(mocks) @@ -576,6 +764,7 @@ Pipelines w: b, codepipeline: mockPLSvc, pipelineLister: mockPipelineLister, + deployStore: mockDeployStore, newVersionGetter: func(s string) (versionGetter, error) { return mockVersionGetter, nil }, diff --git a/internal/pkg/cli/interfaces.go b/internal/pkg/cli/interfaces.go index d6f1c733860..dea4e1a945f 100644 --- a/internal/pkg/cli/interfaces.go +++ b/internal/pkg/cli/interfaces.go @@ -142,6 +142,7 @@ type store interface { type deployedEnvironmentLister interface { ListEnvironmentsDeployedTo(appName, svcName string) ([]string, error) ListDeployedServices(appName, envName string) ([]string, error) + ListDeployedJobs(appName string, envName string) ([]string, error) IsServiceDeployed(appName, envName string, svcName string) (bool, error) ListSNSTopics(appName string, envName string) ([]deploy.Topic, error) } diff --git a/internal/pkg/cli/mocks/mock_interfaces.go b/internal/pkg/cli/mocks/mock_interfaces.go index ca1c397a977..25945893cc0 100644 --- a/internal/pkg/cli/mocks/mock_interfaces.go +++ b/internal/pkg/cli/mocks/mock_interfaces.go @@ -1300,6 +1300,21 @@ func (mr *MockdeployedEnvironmentListerMockRecorder) ListDeployedServices(appNam return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDeployedServices", reflect.TypeOf((*MockdeployedEnvironmentLister)(nil).ListDeployedServices), appName, envName) } +// ListDeployedJobs mocks base method. +func (m *MockdeployedEnvironmentLister) ListDeployedJobs(appName, envName string) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListDeployedJobs", appName, envName) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListDeployedJobs indicates an expected call of ListDeployedJobs. +func (mr *MockdeployedEnvironmentListerMockRecorder) ListDeployedJobs(appName, envName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListDeployedJobs", reflect.TypeOf((*MockdeployedEnvironmentLister)(nil).ListDeployedJobs), appName, envName) +} + // ListEnvironmentsDeployedTo mocks base method. func (m *MockdeployedEnvironmentLister) ListEnvironmentsDeployedTo(appName, svcName string) ([]string, error) { m.ctrl.T.Helper() diff --git a/internal/pkg/describe/app.go b/internal/pkg/describe/app.go index a2576ec7e95..60a99dfc274 100644 --- a/internal/pkg/describe/app.go +++ b/internal/pkg/describe/app.go @@ -23,13 +23,14 @@ import ( // App contains serialized parameters for an application. type App struct { - Name string `json:"name"` - Version string `json:"version"` - URI string `json:"uri"` - Envs []*config.Environment `json:"environments"` - Services []*config.Workload `json:"services"` - Jobs []*config.Workload `json:"jobs"` - Pipelines []*codepipeline.Pipeline `json:"pipelines"` + Name string `json:"name"` + Version string `json:"version"` + URI string `json:"uri"` + Envs []*config.Environment `json:"environments"` + Services []*config.Workload `json:"services"` + Jobs []*config.Workload `json:"jobs"` + Pipelines []*codepipeline.Pipeline `json:"pipelines"` + WkldDeployedtoEnvs map[string][]string `json:"-"` } // JSONString returns the stringified App struct with json format. @@ -64,14 +65,22 @@ func (a *App) HumanString() string { } fmt.Fprint(writer, color.Bold.Sprint("\nWorkloads\n\n")) writer.Flush() - headers = []string{"Name", "Type"} + headers = []string{"Name", "Type", "Environments"} fmt.Fprintf(writer, " %s\n", strings.Join(headers, "\t")) fmt.Fprintf(writer, " %s\n", strings.Join(underline(headers), "\t")) for _, svc := range a.Services { - fmt.Fprintf(writer, " %s\t%s\n", svc.Name, svc.Type) + envs := "-" + if len(a.WkldDeployedtoEnvs[svc.Name]) > 0 { + envs = strings.Join(a.WkldDeployedtoEnvs[svc.Name], ", ") + } + fmt.Fprintf(writer, " %s\t%s\t%s\n", svc.Name, svc.Type, envs) } for _, job := range a.Jobs { - fmt.Fprintf(writer, " %s\t%s\n", job.Name, job.Type) + envs := "-" + if len(a.WkldDeployedtoEnvs[job.Name]) > 0 { + envs = strings.Join(a.WkldDeployedtoEnvs[job.Name], ", ") + } + fmt.Fprintf(writer, " %s\t%s\t%s\n", job.Name, job.Type, envs) } writer.Flush() fmt.Fprint(writer, color.Bold.Sprint("\nPipelines\n\n"))