From 0ca893fe06789e0d95bb65c3520e07cb44c53dbb Mon Sep 17 00:00:00 2001 From: penghaoh Date: Tue, 28 Jan 2020 16:05:37 -0800 Subject: [PATCH] feat(app-logs): add app logs subcommand feat(app-logs): show log events of all log streams in a log group by default --- Makefile | 1 + go.mod | 1 - go.sum | 1 + .../pkg/aws/cloudwatchlogs/cloudwatchlogs.go | 177 ++++ .../aws/cloudwatchlogs/cloudwatchlogs_test.go | 317 +++++++ internal/pkg/aws/cloudwatchlogs/event.go | 52 ++ .../mocks/mock_cloudwatchlogs.go | 64 ++ internal/pkg/cli/app.go | 3 +- internal/pkg/cli/app_logs.go | 390 +++++++++ internal/pkg/cli/app_logs_test.go | 812 ++++++++++++++++++ internal/pkg/cli/app_show.go | 4 +- internal/pkg/cli/app_show_test.go | 2 +- internal/pkg/cli/cli.go | 6 + internal/pkg/cli/flag.go | 26 +- internal/pkg/cli/mocks/mock_cli.go | 59 ++ internal/pkg/term/color/color.go | 3 +- templates/environment/cf.yml | 1 + 17 files changed, 1906 insertions(+), 13 deletions(-) create mode 100644 internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go create mode 100644 internal/pkg/aws/cloudwatchlogs/cloudwatchlogs_test.go create mode 100644 internal/pkg/aws/cloudwatchlogs/event.go create mode 100644 internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go create mode 100644 internal/pkg/cli/app_logs.go create mode 100644 internal/pkg/cli/app_logs_test.go diff --git a/Makefile b/Makefile index a869fd1a617..9d18410b82f 100644 --- a/Makefile +++ b/Makefile @@ -150,4 +150,5 @@ gen-mocks: tools ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/describe/mocks/mock_describe.go -source=./internal/pkg/describe/webapp.go ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/ecr/mocks/mock_ecr.go -source=./internal/pkg/aws/ecr/ecr.go ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/ecs/mocks/mock_ecs.go -source=./internal/pkg/aws/ecs/ecs.go + ${GOBIN}/mockgen -package=mocks -destination=./internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go -source=./internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go ${GOBIN}/mockgen -source=./internal/pkg/build/docker/docker.go -package=mocks -destination=./internal/pkg/build/docker/mocks/mock_docker.go \ No newline at end of file diff --git a/go.mod b/go.mod index 38f1e4f24ac..8c5c5c232e0 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,6 @@ require ( github.com/spf13/viper v1.6.2 github.com/stretchr/testify v1.4.0 golang.org/x/crypto v0.0.0-20190927123631-a832865fa7ad // indirect - golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e // indirect golang.org/x/text v0.3.2 // indirect gopkg.in/ini.v1 v1.52.0 gopkg.in/yaml.v3 v3.0.0-20190905181640-827449938966 diff --git a/go.sum b/go.sum index f4a2faf0d40..a190bbf86fc 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,7 @@ github.com/gobuffalo/packd v0.3.0 h1:eMwymTkA1uXsqxS0Tpoop3Lc0u3kTfiMBE6nKtQU4g4 github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q= github.com/gobuffalo/packd v0.4.0 h1:0jj6ImCqgBbqx874P0qFeowoN3mGilNVY//YXV5SokU= github.com/gobuffalo/packd v0.4.0/go.mod h1:6VTc4htmJRFB7u1m/4LeMTWjFoYrUiBkU9Fdec9hrhI= +github.com/gobuffalo/packr v1.30.1 h1:hu1fuVR3fXEZR7rXNW3h8rqSML8EVAf6KNm0NKO/wKg= github.com/gobuffalo/packr/v2 v2.7.1 h1:n3CIW5T17T8v4GGK5sWXLVWJhCz7b5aNLSxW6gYim4o= github.com/gobuffalo/packr/v2 v2.7.1/go.mod h1:qYEvAazPaVxy7Y7KR0W8qYEE+RymX74kETFqjFoFlOc= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= diff --git a/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go b/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go new file mode 100644 index 00000000000..9eee9d88bbd --- /dev/null +++ b/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go @@ -0,0 +1,177 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package cloudwatchlogs contains utility functions for Cloudwatch Logs client. +package cloudwatchlogs + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" +) + +const ( + // SleepDuration is the sleep time for making the next request for log events. + SleepDuration = 1 * time.Second +) + +var ( + fatalCodes = []string{"FATA", "FATAL", "fatal", "ERR", "ERROR", "error"} + warningCodes = []string{"WARN", "warn", "WARNING", "warning"} +) + +type cloudwatchlogsClient interface { + DescribeLogStreams(input *cloudwatchlogs.DescribeLogStreamsInput) (*cloudwatchlogs.DescribeLogStreamsOutput, error) + GetLogEvents(input *cloudwatchlogs.GetLogEventsInput) (*cloudwatchlogs.GetLogEventsOutput, error) +} + +// Service wraps an AWS Cloudwatch Logs client. +type Service struct { + cwlogs cloudwatchlogsClient +} + +// GetLogEventsOpts sets up optional parameters for LogEvents function. +type GetLogEventsOpts func(*cloudwatchlogs.GetLogEventsInput) + +// WithLimit sets up limit for GetLogEventsInput +func WithLimit(limit int) GetLogEventsOpts { + return func(in *cloudwatchlogs.GetLogEventsInput) { + in.Limit = aws.Int64(int64(limit)) + } +} + +// WithStartTime sets up startTime for GetLogEventsInput +func WithStartTime(startTime int64) GetLogEventsOpts { + return func(in *cloudwatchlogs.GetLogEventsInput) { + in.StartTime = aws.Int64(startTime) + } +} + +// WithEndTime sets up endTime for GetLogEventsInput +func WithEndTime(endTime int64) GetLogEventsOpts { + return func(in *cloudwatchlogs.GetLogEventsInput) { + in.EndTime = aws.Int64(endTime) + } +} + +// LogEventsOutput contains the output for LogEvents +type LogEventsOutput struct { + // Retrieved log events. + Events []*Event + // Next tokens for each log stream. + NextTokens map[string]*string +} + +// New returns a Service configured against the input session. +func New(s *session.Session) *Service { + return &Service{ + cwlogs: cloudwatchlogs.New(s), + } +} + +// logStreams returns all name of the log streams in a log group. +func (s *Service) logStreams(logGroupName string) ([]*string, error) { + resp, err := s.cwlogs.DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String(logGroupName), + Descending: aws.Bool(true), + OrderBy: aws.String(cloudwatchlogs.OrderByLastEventTime), + }) + if err != nil { + return nil, fmt.Errorf("describe log streams of log group %s: %w", logGroupName, err) + } + if len(resp.LogStreams) == 0 { + return nil, fmt.Errorf("no log stream found in log group %s", logGroupName) + } + logStreamNames := make([]*string, len(resp.LogStreams)) + for ind, logStream := range resp.LogStreams { + logStreamNames[ind] = logStream.LogStreamName + } + return logStreamNames, nil +} + +// TaskLogEvents returns an array of Cloudwatch Logs events. +func (s *Service) TaskLogEvents(logGroupName string, streamTokens map[string]*string, opts ...GetLogEventsOpts) (*LogEventsOutput, error) { + var events []*Event + var in *cloudwatchlogs.GetLogEventsInput + nextForwardTokens := make(map[string]*string) + logStreamNames, err := s.logStreams(logGroupName) + if err != nil { + return nil, err + } + for _, logStreamName := range logStreamNames { + in = &cloudwatchlogs.GetLogEventsInput{ + LogGroupName: aws.String(logGroupName), + LogStreamName: logStreamName, + Limit: aws.Int64(10), // default to be 10 + NextToken: streamTokens[*logStreamName], + } + for _, opt := range opts { + opt(in) + } + // TODO: https://github.com/aws/amazon-ecs-cli-v2/pull/628#discussion_r374291068 and https://github.com/aws/amazon-ecs-cli-v2/pull/628#discussion_r374294362 + resp, err := s.cwlogs.GetLogEvents(in) + if err != nil { + return nil, fmt.Errorf("get log events of %s/%s: %w", logGroupName, *logStreamName, err) + } + + for _, event := range resp.Events { + taskID, err := parseTaskID(*logStreamName) + if err != nil { + return nil, err + } + log := &Event{ + TaskID: taskID, + IngestionTime: aws.Int64Value(event.IngestionTime), + Message: aws.StringValue(event.Message), + Timestamp: aws.Int64Value(event.Timestamp), + } + events = append(events, log) + } + + nextForwardTokens[*logStreamName] = resp.NextForwardToken + } + sort.SliceStable(events, func(i, j int) bool { return events[i].Timestamp < events[j].Timestamp }) + var truncatedEvents []*Event + if len(events) >= int(*in.Limit) { + truncatedEvents = events[len(events)-int(*in.Limit):] + } else { + truncatedEvents = events + } + return &LogEventsOutput{ + Events: truncatedEvents, + NextTokens: nextForwardTokens, + }, nil +} + +// LogGroupExists returns if a log group exists. +func (s *Service) LogGroupExists(logGroupName string) (bool, error) { + _, err := s.cwlogs.DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String(logGroupName), + }) + if err == nil { + return true, nil + } + aerr, ok := err.(awserr.Error) + if !ok { + return false, err + } + if aerr.Code() != cloudwatchlogs.ErrCodeResourceNotFoundException { + return false, err + } + return false, nil +} + +func parseTaskID(logStreamName string) (string, error) { + // logStreamName example: ecs/appName/1cc0685ad01d4d0f8e4e2c00d1775c56 + logStreamNameSplit := strings.Split(logStreamName, "/") + if len(logStreamNameSplit) != 3 { + return "", fmt.Errorf("cannot parse task ID from log stream name: %s", logStreamName) + } + return logStreamNameSplit[2], nil +} diff --git a/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs_test.go b/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs_test.go new file mode 100644 index 00000000000..0bc9575e95a --- /dev/null +++ b/internal/pkg/aws/cloudwatchlogs/cloudwatchlogs_test.go @@ -0,0 +1,317 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cloudwatchlogs + +import ( + "errors" + "fmt" + "testing" + + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/cloudwatchlogs/mocks" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestLogEvents(t *testing.T) { + mockError := errors.New("some error") + testCases := map[string]struct { + logGroupName string + startTime int64 + endTime int64 + limit int + mockcloudwatchlogsClient func(m *mocks.MockcloudwatchlogsClient) + + wantLogEvents []*Event + wantErr error + }{ + "should get log stream name and return log events": { + logGroupName: "mockLogGroup", + startTime: 1234567, + endTime: 1234568, + limit: 10, + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }).Return(&cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []*cloudwatchlogs.LogStream{ + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream1"), + }, + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream2"), + }, + }, + }, nil) + + m.EXPECT().GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + StartTime: aws.Int64(1234567), + EndTime: aws.Int64(1234568), + Limit: aws.Int64(10), + LogGroupName: aws.String("mockLogGroup"), + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream1"), + }).Return(&cloudwatchlogs.GetLogEventsOutput{ + Events: []*cloudwatchlogs.OutputLogEvent{ + &cloudwatchlogs.OutputLogEvent{ + Message: aws.String("some log"), + Timestamp: aws.Int64(1), + }, + }, + }, nil) + + m.EXPECT().GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + StartTime: aws.Int64(1234567), + EndTime: aws.Int64(1234568), + Limit: aws.Int64(10), + LogGroupName: aws.String("mockLogGroup"), + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream2"), + }).Return(&cloudwatchlogs.GetLogEventsOutput{ + Events: []*cloudwatchlogs.OutputLogEvent{ + &cloudwatchlogs.OutputLogEvent{ + Message: aws.String("other log"), + Timestamp: aws.Int64(0), + }, + }, + }, nil) + }, + + wantLogEvents: []*Event{ + &Event{ + TaskID: "mockLogStream2", + Message: "other log", + Timestamp: 0, + }, + &Event{ + TaskID: "mockLogStream1", + Message: "some log", + Timestamp: 1, + }, + }, + wantErr: nil, + }, + "should return limited number of log events": { + logGroupName: "mockLogGroup", + startTime: 1234567, + endTime: 1234568, + limit: 1, + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }).Return(&cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []*cloudwatchlogs.LogStream{ + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream1"), + }, + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream2"), + }, + }, + }, nil) + + m.EXPECT().GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + StartTime: aws.Int64(1234567), + EndTime: aws.Int64(1234568), + Limit: aws.Int64(1), + LogGroupName: aws.String("mockLogGroup"), + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream1"), + }).Return(&cloudwatchlogs.GetLogEventsOutput{ + Events: []*cloudwatchlogs.OutputLogEvent{ + &cloudwatchlogs.OutputLogEvent{ + Message: aws.String("some log"), + Timestamp: aws.Int64(1), + }, + }, + }, nil) + + m.EXPECT().GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + StartTime: aws.Int64(1234567), + EndTime: aws.Int64(1234568), + Limit: aws.Int64(1), + LogGroupName: aws.String("mockLogGroup"), + LogStreamName: aws.String("ecs/mockLogGroup/mockLogStream2"), + }).Return(&cloudwatchlogs.GetLogEventsOutput{ + Events: []*cloudwatchlogs.OutputLogEvent{ + &cloudwatchlogs.OutputLogEvent{ + Message: aws.String("other log"), + Timestamp: aws.Int64(0), + }, + }, + }, nil) + }, + + wantLogEvents: []*Event{ + &Event{ + TaskID: "mockLogStream1", + Message: "some log", + Timestamp: 1, + }, + }, + wantErr: nil, + }, + "returns error if fail to describe log streams": { + logGroupName: "mockLogGroup", + startTime: 1234567, + endTime: 1234568, + limit: 10, + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }).Return(nil, mockError) + }, + + wantLogEvents: nil, + wantErr: fmt.Errorf("describe log streams of log group %s: %w", "mockLogGroup", mockError), + }, + "returns error if no log stream found": { + logGroupName: "mockLogGroup", + startTime: 1234567, + endTime: 1234568, + limit: 10, + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }).Return(&cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []*cloudwatchlogs.LogStream{}, + }, nil) + }, + + wantLogEvents: nil, + wantErr: fmt.Errorf("no log stream found in log group %s", "mockLogGroup"), + }, + "returns error if fail to get log events": { + logGroupName: "mockLogGroup", + startTime: 1234567, + endTime: 1234568, + limit: 10, + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + Descending: aws.Bool(true), + OrderBy: aws.String("LastEventTime"), + }).Return(&cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []*cloudwatchlogs.LogStream{ + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("mockLogStream"), + }, + }, + }, nil) + m.EXPECT().GetLogEvents(&cloudwatchlogs.GetLogEventsInput{ + StartTime: aws.Int64(1234567), + EndTime: aws.Int64(1234568), + Limit: aws.Int64(10), + LogGroupName: aws.String("mockLogGroup"), + LogStreamName: aws.String("mockLogStream"), + }).Return(nil, mockError) + }, + + wantLogEvents: nil, + wantErr: fmt.Errorf("get log events of %s/%s: %w", "mockLogGroup", "mockLogStream", mockError), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockcloudwatchlogsClient := mocks.NewMockcloudwatchlogsClient(ctrl) + tc.mockcloudwatchlogsClient(mockcloudwatchlogsClient) + + service := Service{ + cwlogs: mockcloudwatchlogsClient, + } + + gotLogEventsOutput, gotErr := service.TaskLogEvents(tc.logGroupName, nil, WithLimit(tc.limit), WithStartTime(tc.startTime), WithEndTime(tc.endTime)) + + if gotErr != nil { + require.Equal(t, tc.wantErr, gotErr) + } else { + require.ElementsMatch(t, tc.wantLogEvents, gotLogEventsOutput.Events) + } + }) + } +} + +func TestLogGroupExists(t *testing.T) { + mockError := errors.New("some error") + testCases := map[string]struct { + logGroupName string + + mockcloudwatchlogsClient func(m *mocks.MockcloudwatchlogsClient) + + exist bool + wantErr error + }{ + "should return true if a log group exists": { + logGroupName: "mockLogGroup", + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + }).Return(&cloudwatchlogs.DescribeLogStreamsOutput{ + LogStreams: []*cloudwatchlogs.LogStream{ + &cloudwatchlogs.LogStream{ + LogStreamName: aws.String("mockLogStream"), + }, + }, + }, nil) + }, + + exist: true, + wantErr: nil, + }, + "should return false if a log group does not exist": { + logGroupName: "mockLogGroup", + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + }).Return(nil, awserr.New("ResourceNotFoundException", "some error", nil)) + }, + + exist: false, + wantErr: nil, + }, + "should return error if fail to describe log stream": { + logGroupName: "mockLogGroup", + mockcloudwatchlogsClient: func(m *mocks.MockcloudwatchlogsClient) { + m.EXPECT().DescribeLogStreams(&cloudwatchlogs.DescribeLogStreamsInput{ + LogGroupName: aws.String("mockLogGroup"), + }).Return(nil, mockError) + }, + + exist: false, + wantErr: mockError, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + // GIVEN + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockcloudwatchlogsClient := mocks.NewMockcloudwatchlogsClient(ctrl) + tc.mockcloudwatchlogsClient(mockcloudwatchlogsClient) + + service := Service{ + cwlogs: mockcloudwatchlogsClient, + } + + exist, gotErr := service.LogGroupExists(tc.logGroupName) + + require.Equal(t, tc.exist, exist) + require.Equal(t, tc.wantErr, gotErr) + }) + } +} diff --git a/internal/pkg/aws/cloudwatchlogs/event.go b/internal/pkg/aws/cloudwatchlogs/event.go new file mode 100644 index 00000000000..65f2f7610c0 --- /dev/null +++ b/internal/pkg/aws/cloudwatchlogs/event.go @@ -0,0 +1,52 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package cloudwatchlogs contains utility functions for Cloudwatch Logs client. +package cloudwatchlogs + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color" +) + +const ( + shortTaskIDLength = 7 +) + +// Event represents a log event. +type Event struct { + TaskID string `json:"taskID"` + IngestionTime int64 `json:"ingestionTime"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +// JSONString returns the stringified LogEvent struct with json format. +func (l *Event) JSONString() (string, error) { + b, err := json.Marshal(l) + if err != nil { + return "", fmt.Errorf("marshal a log event: %w", err) + } + return fmt.Sprintf("%s\n", b), nil +} + +// HumanString returns the stringified LogEvent struct with human readable format. +func (l *Event) HumanString() string { + for _, code := range fatalCodes { + l.Message = strings.ReplaceAll(l.Message, code, color.Red.Sprint(code)) + } + for _, code := range warningCodes { + l.Message = strings.ReplaceAll(l.Message, code, color.Yellow.Sprint(code)) + } + return fmt.Sprintf("%s %s\n", color.Grey.Sprint(l.shortTaskID()), l.Message) +} + +func (l *Event) shortTaskID() string { + if len(l.TaskID) < shortTaskIDLength { + return l.TaskID + } + return l.TaskID[0:shortTaskIDLength] +} diff --git a/internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go b/internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go new file mode 100644 index 00000000000..e013390c8d0 --- /dev/null +++ b/internal/pkg/aws/cloudwatchlogs/mocks/mock_cloudwatchlogs.go @@ -0,0 +1,64 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ./internal/pkg/aws/cloudwatchlogs/cloudwatchlogs.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + cloudwatchlogs "github.com/aws/aws-sdk-go/service/cloudwatchlogs" + gomock "github.com/golang/mock/gomock" + reflect "reflect" +) + +// MockcloudwatchlogsClient is a mock of cloudwatchlogsClient interface +type MockcloudwatchlogsClient struct { + ctrl *gomock.Controller + recorder *MockcloudwatchlogsClientMockRecorder +} + +// MockcloudwatchlogsClientMockRecorder is the mock recorder for MockcloudwatchlogsClient +type MockcloudwatchlogsClientMockRecorder struct { + mock *MockcloudwatchlogsClient +} + +// NewMockcloudwatchlogsClient creates a new mock instance +func NewMockcloudwatchlogsClient(ctrl *gomock.Controller) *MockcloudwatchlogsClient { + mock := &MockcloudwatchlogsClient{ctrl: ctrl} + mock.recorder = &MockcloudwatchlogsClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockcloudwatchlogsClient) EXPECT() *MockcloudwatchlogsClientMockRecorder { + return m.recorder +} + +// DescribeLogStreams mocks base method +func (m *MockcloudwatchlogsClient) DescribeLogStreams(input *cloudwatchlogs.DescribeLogStreamsInput) (*cloudwatchlogs.DescribeLogStreamsOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DescribeLogStreams", input) + ret0, _ := ret[0].(*cloudwatchlogs.DescribeLogStreamsOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DescribeLogStreams indicates an expected call of DescribeLogStreams +func (mr *MockcloudwatchlogsClientMockRecorder) DescribeLogStreams(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeLogStreams", reflect.TypeOf((*MockcloudwatchlogsClient)(nil).DescribeLogStreams), input) +} + +// GetLogEvents mocks base method +func (m *MockcloudwatchlogsClient) GetLogEvents(input *cloudwatchlogs.GetLogEventsInput) (*cloudwatchlogs.GetLogEventsOutput, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetLogEvents", input) + ret0, _ := ret[0].(*cloudwatchlogs.GetLogEventsOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetLogEvents indicates an expected call of GetLogEvents +func (mr *MockcloudwatchlogsClientMockRecorder) GetLogEvents(input interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogEvents", reflect.TypeOf((*MockcloudwatchlogsClient)(nil).GetLogEvents), input) +} diff --git a/internal/pkg/cli/app.go b/internal/pkg/cli/app.go index db7810137c2..6238a579a7d 100644 --- a/internal/pkg/cli/app.go +++ b/internal/pkg/cli/app.go @@ -1,4 +1,4 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cli @@ -28,6 +28,7 @@ An application represents an Amazon ECS service or task.`, cmd.AddCommand(BuildAppDeployCmd()) cmd.AddCommand(BuildAppDeleteCmd()) cmd.AddCommand(BuildAppShowCmd()) + cmd.AddCommand(BuildAppLogsCmd()) cmd.SetUsageTemplate(template.Usage) diff --git a/internal/pkg/cli/app_logs.go b/internal/pkg/cli/app_logs.go new file mode 100644 index 00000000000..2370c3d07e7 --- /dev/null +++ b/internal/pkg/cli/app_logs.go @@ -0,0 +1,390 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "errors" + "fmt" + "io" + "time" + + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/cloudwatchlogs" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/session" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/store" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/log" + "github.com/spf13/cobra" +) + +const ( + applicationLogProjectNamePrompt = "Which project does your application belong to?" + applicationLogProjectNameHelpPrompt = "A project groups all of your applications together." + applicationLogAppNamePrompt = "Which application's logs would you like to show?" + applicationLogAppNameHelpPrompt = "The logs of a deployed application will be shown." + + logGroupNamePattern = "/ecs/%s-%s-%s" + cwGetLogEventsLimitMin = 1 + cwGetLogEventsLimitMax = 10000 +) + +type appEnv struct { + appName string + envName string +} + +func (a *appEnv) String() string { + return fmt.Sprintf("%s (%s)", a.appName, a.envName) +} + +type appLogsVars struct { + shouldOutputJSON bool + follow bool + limit int + appName string + envName string + humanStartTime string + humanEndTime string + since time.Duration + *GlobalOpts +} + +type appLogsOpts struct { + appLogsVars + + // internal states + startTime int64 + endTime int64 + + w io.Writer + storeSvc storeReader + initCwLogsSvc func(*appLogsOpts, *archer.Environment) error // Overriden in tests. + cwlogsSvc map[string]cwlogService +} + +func newAppLogOpts(vars appLogsVars) (*appLogsOpts, error) { + ssmStore, err := store.New() + if err != nil { + return nil, fmt.Errorf("connect to environment datastore: %w", err) + } + + return &appLogsOpts{ + appLogsVars: vars, + w: log.OutputWriter, + storeSvc: ssmStore, + initCwLogsSvc: func(o *appLogsOpts, env *archer.Environment) error { + sess, err := session.NewProvider().FromRole(env.ManagerRoleARN, env.Region) + if err != nil { + return err + } + o.cwlogsSvc[env.Name] = cloudwatchlogs.New(sess) + return nil + }, + cwlogsSvc: make(map[string]cwlogService), + }, nil +} + +// Validate returns an error if the values provided by the user are invalid. +func (o *appLogsOpts) Validate() error { + if o.ProjectName() != "" { + _, err := o.storeSvc.GetProject(o.ProjectName()) + if err != nil { + return err + } + } + + if o.since != 0 && o.humanStartTime != "" { + return errors.New("only one of --since or --start-time may be used") + } + + if o.humanEndTime != "" && o.follow { + return errors.New("only one of --follow or --end-time may be used") + } + + if o.since != 0 { + if o.since < 0 { + return fmt.Errorf("--since must be greater than 0") + } + // round up to the nearest second + o.startTime = o.parseSince() + } + + if o.humanStartTime != "" { + startTime, err := o.parseRFC3339(o.humanStartTime) + if err != nil { + return fmt.Errorf(`invalid argument %s for "--start-time" flag: %w`, o.humanStartTime, err) + } + o.startTime = startTime + } + + if o.humanEndTime != "" { + endTime, err := o.parseRFC3339(o.humanEndTime) + if err != nil { + return fmt.Errorf(`invalid argument %s for "--end-time" flag: %w`, o.humanEndTime, err) + } + o.endTime = endTime + } + + if o.limit < cwGetLogEventsLimitMin || o.limit > cwGetLogEventsLimitMax { + return fmt.Errorf("--limit %d is out-of-bounds, value must be between %d and %d", o.limit, cwGetLogEventsLimitMin, cwGetLogEventsLimitMax) + } + + return nil +} + +// Ask asks for fields that are required but not passed in. +func (o *appLogsOpts) Ask() error { + if err := o.askProject(); err != nil { + return err + } + return o.askAppEnvName() +} + +// Execute shows the applications through the prompt. +func (o *appLogsOpts) Execute() error { + logGroupName := fmt.Sprintf(logGroupNamePattern, o.ProjectName(), o.envName, o.appName) + logEventsOutput := &cloudwatchlogs.LogEventsOutput{ + NextTokens: nil, + } + var err error + for { + logEventsOutput, err = o.cwlogsSvc[o.envName].TaskLogEvents(logGroupName, logEventsOutput.NextTokens, o.generateGetLogEventOpts()...) + if err != nil { + return err + } + if err := o.outputLogs(logEventsOutput.Events); err != nil { + return err + } + if !o.follow { + return nil + } + // for unit test. + if logEventsOutput.NextTokens == nil { + return nil + } + time.Sleep(cloudwatchlogs.SleepDuration) + } +} + +func (o *appLogsOpts) askProject() error { + if o.ProjectName() != "" { + return nil + } + projNames, err := o.retrieveProjectNames() + if err != nil { + return err + } + if len(projNames) == 0 { + return fmt.Errorf("no project found: run %s please", color.HighlightCode("project init")) + } + proj, err := o.prompt.SelectOne( + applicationLogProjectNamePrompt, + applicationLogProjectNameHelpPrompt, + projNames, + ) + if err != nil { + return fmt.Errorf("select projects: %w", err) + } + o.projectName = proj + + return nil +} + +func (o *appLogsOpts) generateGetLogEventOpts() []cloudwatchlogs.GetLogEventsOpts { + opts := []cloudwatchlogs.GetLogEventsOpts{ + cloudwatchlogs.WithLimit(o.limit), + } + if o.startTime != 0 { + opts = append(opts, cloudwatchlogs.WithStartTime(o.startTime)) + } + if o.endTime != 0 { + opts = append(opts, cloudwatchlogs.WithEndTime(o.endTime)) + } + return opts +} + +func (o *appLogsOpts) askAppEnvName() error { + var appNames []string + var envs []*archer.Environment + var err error + if o.appName == "" { + appNames, err = o.retrieveAllAppNames() + if err != nil { + return err + } + if len(appNames) == 0 { + return fmt.Errorf("no applications found in project %s", color.HighlightUserInput(o.ProjectName())) + } + } else { + app, err := o.storeSvc.GetApplication(o.ProjectName(), o.appName) + if err != nil { + return fmt.Errorf("get application: %w", err) + } + appNames = []string{app.Name} + } + + if o.envName == "" { + envs, err = o.storeSvc.ListEnvironments(o.ProjectName()) + if err != nil { + return fmt.Errorf("list environments: %w", err) + } + if len(envs) == 0 { + return fmt.Errorf("no environments found in project %s", color.HighlightUserInput(o.ProjectName())) + } + } else { + env, err := o.storeSvc.GetEnvironment(o.ProjectName(), o.envName) + if err != nil { + return fmt.Errorf("get environment: %w", err) + } + envs = []*archer.Environment{env} + } + + appEnvs := make(map[string]appEnv) + var appEnvNames []string + for _, appName := range appNames { + for _, env := range envs { + if err := o.initCwLogsSvc(o, env); err != nil { + return err + } + deployed, err := o.cwlogsSvc[env.Name].LogGroupExists(fmt.Sprintf(logGroupNamePattern, o.ProjectName(), env.Name, appName)) + if err != nil { + return fmt.Errorf("check if the log group exists: %w", err) + } + if !deployed { + continue + } + appEnv := appEnv{ + appName: appName, + envName: env.Name, + } + appEnvName := appEnv.String() + appEnvs[appEnvName] = appEnv + appEnvNames = append(appEnvNames, appEnvName) + } + } + if len(appEnvNames) == 0 { + return fmt.Errorf("no deployed applications found in project %s", color.HighlightUserInput(o.ProjectName())) + } + + // return if only one deployed app found + if len(appEnvNames) == 1 { + log.Infof("Only found one deployed app, defaulting to: %s\n", color.HighlightUserInput(appEnvNames[0])) + o.appName = appEnvs[appEnvNames[0]].appName + o.envName = appEnvs[appEnvNames[0]].envName + + return nil + } + + appEnvName, err := o.prompt.SelectOne( + fmt.Sprintf(applicationLogAppNamePrompt), + applicationLogAppNameHelpPrompt, + appEnvNames, + ) + if err != nil { + return fmt.Errorf("select deployed applications for project %s: %w", o.ProjectName(), err) + } + o.appName = appEnvs[appEnvName].appName + o.envName = appEnvs[appEnvName].envName + + return nil +} + +func (o *appLogsOpts) retrieveProjectNames() ([]string, error) { + projs, err := o.storeSvc.ListProjects() + if err != nil { + return nil, fmt.Errorf("list projects: %w", err) + } + projNames := make([]string, len(projs)) + for ind, proj := range projs { + projNames[ind] = proj.Name + } + return projNames, nil +} + +func (o *appLogsOpts) retrieveAllAppNames() ([]string, error) { + apps, err := o.storeSvc.ListApplications(o.ProjectName()) + if err != nil { + return nil, fmt.Errorf("list applications for project %s: %w", o.ProjectName(), err) + } + appNames := make([]string, len(apps)) + for ind, app := range apps { + appNames[ind] = app.Name + } + + return appNames, nil +} + +func (o *appLogsOpts) outputLogs(logs []*cloudwatchlogs.Event) error { + if !o.shouldOutputJSON { + for _, log := range logs { + fmt.Fprintf(o.w, log.HumanString()) + } + return nil + } + for _, log := range logs { + data, err := log.JSONString() + if err != nil { + return err + } + fmt.Fprintf(o.w, data) + } + return nil +} + +func (o *appLogsOpts) parseSince() int64 { + sinceSec := int64(o.since.Round(time.Second).Seconds()) + timeNow := time.Now().Add(time.Duration(-sinceSec) * time.Second) + return timeNow.Unix() * 1000 +} + +func (o *appLogsOpts) parseRFC3339(timeStr string) (int64, error) { + startTimeTmp, err := time.Parse(time.RFC3339, timeStr) + if err != nil { + return 0, fmt.Errorf("reading time value %s: %w", timeStr, err) + } + return startTimeTmp.Unix() * 1000, nil +} + +// BuildAppLogsCmd builds the command for displaying application logs in a project. +func BuildAppLogsCmd() *cobra.Command { + vars := appLogsVars{ + GlobalOpts: NewGlobalOpts(), + } + cmd := &cobra.Command{ + Use: "logs", + Short: "Displays logs of a deployed application.", + + Example: ` + Displays logs of the application "my-app" in environment "test" + /code $ ecs-preview app logs -n my-app -e test + Displays logs in the last hour + /code $ ecs-preview app logs --since 1h + Displays logs from 2006-01-02T15:04:05 to 2006-01-02T15:05:05 + /code $ ecs-preview app logs --start-time 2006-01-02T15:04:05+00:00 --end-time 2006-01-02T15:05:05+00:00`, + RunE: runCmdE(func(cmd *cobra.Command, args []string) error { + opts, err := newAppLogOpts(vars) + if err != nil { + return err + } + if err := opts.Validate(); err != nil { + return err + } + if err := opts.Ask(); err != nil { + return err + } + return opts.Execute() + }), + } + // The flags bound by viper are available to all sub-commands through viper.GetString({flagName}) + cmd.Flags().StringVarP(&vars.appName, nameFlag, nameFlagShort, "", appFlagDescription) + cmd.Flags().StringVarP(&vars.envName, envFlag, envFlagShort, "", envFlagDescription) + cmd.Flags().StringVar(&vars.humanStartTime, startTimeFlag, "", startTimeFlagDescription) + cmd.Flags().StringVar(&vars.humanEndTime, endTimeFlag, "", endTimeFlagDescription) + cmd.Flags().BoolVar(&vars.shouldOutputJSON, jsonFlag, false, jsonFlagDescription) + cmd.Flags().BoolVar(&vars.follow, followFlag, false, followFlagDescription) + cmd.Flags().DurationVar(&vars.since, sinceFlag, 0, sinceFlagDescription) + cmd.Flags().IntVar(&vars.limit, limitFlag, 10, limitFlagDescription) + cmd.Flags().StringVarP(&vars.projectName, projectFlag, projectFlagShort, "", projectFlagDescription) + return cmd +} diff --git a/internal/pkg/cli/app_logs_test.go b/internal/pkg/cli/app_logs_test.go new file mode 100644 index 00000000000..f98bc68bd9a --- /dev/null +++ b/internal/pkg/cli/app_logs_test.go @@ -0,0 +1,812 @@ +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "errors" + "fmt" + "testing" + "time" + + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/cloudwatchlogs" + climocks "github.com/aws/amazon-ecs-cli-v2/internal/pkg/cli/mocks" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color" + "github.com/aws/aws-sdk-go/aws" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" +) + +func TestAppLogs_Validate(t *testing.T) { + const ( + mockLimit = 3 + mockSince = 1 * time.Minute + mockStartTime = "1970-01-01T01:01:01+00:00" + mockBadStartTime = "badStartTime" + mockEndTime = "1971-01-01T01:01:01+00:00" + mockBadEndTime = "badEndTime" + ) + testCases := map[string]struct { + inputProject string + inputApplication string + inputLimit int + inputFollow bool + inputEnvName string + inputStartTime string + inputEndTime string + inputSince time.Duration + + mockStoreReader func(m *climocks.MockstoreReader) + mockcwlogService func(ctrl *gomock.Controller) map[string]cwlogService + + wantedError error + }{ + "with no flag set": { + // default value for limit and since flags + inputLimit: 10, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: nil, + }, + "invalid project name": { + inputProject: "my-project", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetProject("my-project").Return(nil, errors.New("some error")) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("some error"), + }, + "returns error if since and startTime flags are set together": { + inputSince: mockSince, + inputStartTime: mockStartTime, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("only one of --since or --start-time may be used"), + }, + "returns error if follow and endTime flags are set together": { + inputFollow: true, + inputEndTime: mockEndTime, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("only one of --follow or --end-time may be used"), + }, + "returns error if invalid start time flag value": { + inputStartTime: mockBadStartTime, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("invalid argument badStartTime for \"--start-time\" flag: reading time value badStartTime: parsing time \"badStartTime\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"badStartTime\" as \"2006\""), + }, + "returns error if invalid end time flag value": { + inputEndTime: mockBadEndTime, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("invalid argument badEndTime for \"--end-time\" flag: reading time value badEndTime: parsing time \"badEndTime\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"badEndTime\" as \"2006\""), + }, + "returns error if invalid since flag value": { + inputSince: -mockSince, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("--since must be greater than 0"), + }, + "returns error if limit value is below limit": { + inputLimit: -1, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("--limit -1 is out-of-bounds, value must be between 1 and 10000"), + }, + "returns error if limit value is above limit": { + inputLimit: 10001, + + mockStoreReader: func(m *climocks.MockstoreReader) {}, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + + wantedError: fmt.Errorf("--limit 10001 is out-of-bounds, value must be between 1 and 10000"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStoreReader := climocks.NewMockstoreReader(ctrl) + tc.mockStoreReader(mockStoreReader) + + appLogs := &appLogsOpts{ + appLogsVars: appLogsVars{ + follow: tc.inputFollow, + limit: tc.inputLimit, + envName: tc.inputEnvName, + humanStartTime: tc.inputStartTime, + humanEndTime: tc.inputEndTime, + since: tc.inputSince, + appName: tc.inputApplication, + GlobalOpts: &GlobalOpts{ + projectName: tc.inputProject, + }, + }, + storeSvc: mockStoreReader, + initCwLogsSvc: func(*appLogsOpts, *archer.Environment) error { return nil }, + cwlogsSvc: tc.mockcwlogService(ctrl), + } + + // WHEN + err := appLogs.Validate() + + // THEN + if tc.wantedError != nil { + require.EqualError(t, err, tc.wantedError.Error()) + } else { + require.Nil(t, err) + } + }) + } +} + +func TestAppLogs_Ask(t *testing.T) { + testCases := map[string]struct { + inputProject string + inputApplication string + inputEnvName string + + mockStoreReader func(m *climocks.MockstoreReader) + mockcwlogService func(ctrl *gomock.Controller) map[string]cwlogService + mockPrompter func(m *climocks.Mockprompter) + + wantedError error + }{ + "with all flag set": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetApplication("mockProject", "mockApp").Return(&archer.Application{ + Name: "mockApp", + }, nil) + m.EXPECT().GetEnvironment("mockProject", "mockEnv").Return(&archer.Environment{ + Name: "mockEnv", + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp")).Return(true, nil) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) {}, + + wantedError: nil, + }, + "with all flag set and return error if fail to get application": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetApplication("mockProject", "mockApp").Return(nil, errors.New("some error")) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) {}, + + wantedError: fmt.Errorf("get application: some error"), + }, + "with all flag set and return error if fail to get environment": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetApplication("mockProject", "mockApp").Return(&archer.Application{ + Name: "mockApp", + }, nil) + m.EXPECT().GetEnvironment("mockProject", "mockEnv").Return(nil, errors.New("some error")) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) {}, + + wantedError: fmt.Errorf("get environment: some error"), + }, + "with only app flag set and not deployed in one of envs": { + inputProject: "mockProject", + inputApplication: "mockApp", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetApplication("mockProject", "mockApp").Return(&archer.Application{ + Name: "mockApp", + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockEnv", + }, + &archer.Environment{ + Name: "mockTestEnv", + }, + &archer.Environment{ + Name: "mockProdEnv", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockTestEnv", "mockApp")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockProdEnv", "mockApp")).Return(false, nil) + cwlogServices["mockEnv"] = m + cwlogServices["mockTestEnv"] = m + cwlogServices["mockProdEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(fmt.Sprintf(applicationLogAppNamePrompt), applicationLogAppNameHelpPrompt, []string{"mockApp (mockEnv)", "mockApp (mockTestEnv)"}).Return("mockApp (mockTestEnv)", nil).Times(1) + }, + + wantedError: nil, + }, + "with only env flag set": { + inputProject: "mockProject", + inputEnvName: "mockEnv", + + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().GetEnvironment("mockProject", "mockEnv").Return(&archer.Environment{ + Name: "mockEnv", + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockFrontend", + }, + &archer.Application{ + Name: "mockBackend", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockFrontend")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockBackend")).Return(true, nil) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(fmt.Sprintf(applicationLogAppNamePrompt), applicationLogAppNameHelpPrompt, []string{"mockFrontend (mockEnv)", "mockBackend (mockEnv)"}).Return("mockFrontend (mockEnv)", nil).Times(1) + }, + + wantedError: nil, + }, + "retrieve app name from ssm store": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockTestEnv", + }, + &archer.Environment{ + Name: "mockProdEnv", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockTestEnv", "mockApp")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockProdEnv", "mockApp")).Return(true, nil) + cwlogServices["mockTestEnv"] = m + cwlogServices["mockProdEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + m.EXPECT().SelectOne(fmt.Sprintf(applicationLogAppNamePrompt), applicationLogAppNameHelpPrompt, []string{"mockApp (mockTestEnv)", "mockApp (mockProdEnv)"}).Return("mockApp (mockTestEnv)", nil).Times(1) + }, + + wantedError: nil, + }, + "skip selecting if only one deployed app found": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockTestEnv", + }, + &archer.Environment{ + Name: "mockProdEnv", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockTestEnv", "mockApp")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockProdEnv", "mockApp")).Return(false, nil) + cwlogServices["mockTestEnv"] = m + cwlogServices["mockProdEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: nil, + }, + "returns error if fail to list projects": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return(nil, errors.New("some error")) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) {}, + + wantedError: fmt.Errorf("list projects: some error"), + }, + "returns error if no project found": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{}, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) {}, + + wantedError: fmt.Errorf("no project found: run %s please", color.HighlightCode("project init")), + }, + "returns error if fail to select project": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("", errors.New("some error")) + }, + + wantedError: fmt.Errorf("select projects: some error"), + }, + "returns error if fail to retrieve application": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return(nil, errors.New("some error")) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("list applications for project mockProject: some error"), + }, + "returns error if no applications found": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{}, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("no applications found in project %s", color.HighlightUserInput("mockProject")), + }, + "returns error if fail to list environments": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return(nil, errors.New("some error")) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("list environments: some error"), + }, + "returns error if no environment found": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{}, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + return nil + }, mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("no environments found in project %s", color.HighlightUserInput("mockProject")), + }, + "returns error if fail to check application deployed or not": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockEnv", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp")).Return(false, errors.New("some error")) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("check if the log group exists: some error"), + }, + "returns error if no deployed application found": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockEnv", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp")).Return(false, nil) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + }, + + wantedError: fmt.Errorf("no deployed applications found in project %s", color.HighlightUserInput("mockProject")), + }, + "returns error if fail to select app env name": { + mockStoreReader: func(m *climocks.MockstoreReader) { + m.EXPECT().ListProjects().Return([]*archer.Project{ + &archer.Project{ + Name: "mockProject", + }, + }, nil) + m.EXPECT().ListEnvironments("mockProject").Return([]*archer.Environment{ + &archer.Environment{ + Name: "mockTestEnv", + }, + &archer.Environment{ + Name: "mockProdEnv", + }, + }, nil) + m.EXPECT().ListApplications("mockProject").Return([]*archer.Application{ + &archer.Application{ + Name: "mockApp", + }, + }, nil) + }, + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockTestEnv", "mockApp")).Return(true, nil) + m.EXPECT().LogGroupExists(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockProdEnv", "mockApp")).Return(true, nil) + cwlogServices["mockTestEnv"] = m + cwlogServices["mockProdEnv"] = m + return cwlogServices + }, + mockPrompter: func(m *climocks.Mockprompter) { + m.EXPECT().SelectOne(applicationLogProjectNamePrompt, applicationLogProjectNameHelpPrompt, []string{"mockProject"}).Return("mockProject", nil) + m.EXPECT().SelectOne(fmt.Sprintf(applicationLogAppNamePrompt), applicationLogAppNameHelpPrompt, []string{"mockApp (mockTestEnv)", "mockApp (mockProdEnv)"}).Return("", errors.New("some error")).Times(1) + }, + + wantedError: fmt.Errorf("select deployed applications for project mockProject: some error"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockStoreReader := climocks.NewMockstoreReader(ctrl) + mockPrompter := climocks.NewMockprompter(ctrl) + tc.mockStoreReader(mockStoreReader) + tc.mockPrompter(mockPrompter) + + appLogs := &appLogsOpts{ + appLogsVars: appLogsVars{ + envName: tc.inputEnvName, + appName: tc.inputApplication, + GlobalOpts: &GlobalOpts{ + projectName: tc.inputProject, + prompt: mockPrompter, + }, + }, + storeSvc: mockStoreReader, + initCwLogsSvc: func(*appLogsOpts, *archer.Environment) error { return nil }, + cwlogsSvc: tc.mockcwlogService(ctrl), + } + + // WHEN + err := appLogs.Ask() + + // THEN + if tc.wantedError != nil { + require.EqualError(t, err, tc.wantedError.Error()) + } else { + require.Nil(t, err) + } + }) + } +} + +func TestAppLogs_Execute(t *testing.T) { + mockNextToken := map[string]*string{ + "mockLogStreamName": aws.String("mockNextToken"), + } + logEvents := []*cloudwatchlogs.Event{ + &cloudwatchlogs.Event{ + TaskID: "123456789", + Message: `10.0.0.00 - - [01/Jan/1970 01:01:01] "GET / HTTP/1.1" 200 -`, + }, + &cloudwatchlogs.Event{ + TaskID: "123456789", + Message: `10.0.0.00 - - [01/Jan/1970 01:01:01] "FATA some error" - -`, + }, + &cloudwatchlogs.Event{ + TaskID: "123456789", + Message: `10.0.0.00 - - [01/Jan/1970 01:01:01] "WARN some warning" - -`, + }, + } + moreLogEvents := []*cloudwatchlogs.Event{ + &cloudwatchlogs.Event{ + TaskID: "123456789", + Message: `10.0.0.00 - - [01/Jan/1970 01:01:01] "GET / HTTP/1.1" 404 -`, + }, + } + logEventsHumanString := `1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "GET / HTTP/1.1" 200 - +1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "FATA some error" - - +1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "WARN some warning" - - +` + logEventsJSONString := "{\"taskID\":\"123456789\",\"ingestionTime\":0,\"message\":\"10.0.0.00 - - [01/Jan/1970 01:01:01] \\\"GET / HTTP/1.1\\\" 200 -\",\"timestamp\":0}\n{\"taskID\":\"123456789\",\"ingestionTime\":0,\"message\":\"10.0.0.00 - - [01/Jan/1970 01:01:01] \\\"FATA some error\\\" - -\",\"timestamp\":0}\n{\"taskID\":\"123456789\",\"ingestionTime\":0,\"message\":\"10.0.0.00 - - [01/Jan/1970 01:01:01] \\\"WARN some warning\\\" - -\",\"timestamp\":0}\n" + testCases := map[string]struct { + inputProject string + inputApplication string + inputFollow bool + inputEnvName string + inputJSON bool + + mockcwlogService func(ctrl *gomock.Controller) map[string]cwlogService + + wantedError error + wantedContent string + }{ + "with no optional flags set": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().TaskLogEvents(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp"), nil, gomock.Any()). + Return(&cloudwatchlogs.LogEventsOutput{ + Events: logEvents, + NextTokens: nil, + }, nil) + + cwlogServices["mockEnv"] = m + return cwlogServices + }, + + wantedError: nil, + wantedContent: logEventsHumanString, + }, + "with json flag set": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + inputJSON: true, + + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().TaskLogEvents(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp"), nil, gomock.Any()). + Return(&cloudwatchlogs.LogEventsOutput{ + Events: logEvents, + }, nil) + + cwlogServices["mockEnv"] = m + return cwlogServices + }, + + wantedError: nil, + wantedContent: logEventsJSONString, + }, + "with follow flag set": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + inputFollow: true, + + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().TaskLogEvents(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp"), nil, gomock.Any()).Return(&cloudwatchlogs.LogEventsOutput{ + Events: logEvents, + NextTokens: mockNextToken, + }, nil) + m.EXPECT().TaskLogEvents(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp"), mockNextToken, gomock.Any()).Return(&cloudwatchlogs.LogEventsOutput{ + Events: moreLogEvents, + NextTokens: nil, + }, nil) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + + wantedError: nil, + wantedContent: `1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "GET / HTTP/1.1" 200 - +1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "FATA some error" - - +1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "WARN some warning" - - +1234567 10.0.0.00 - - [01/Jan/1970 01:01:01] "GET / HTTP/1.1" 404 - +`, + }, + "returns error if fail to get event logs": { + inputProject: "mockProject", + inputApplication: "mockApp", + inputEnvName: "mockEnv", + + mockcwlogService: func(ctrl *gomock.Controller) map[string]cwlogService { + m := climocks.NewMockcwlogService(ctrl) + cwlogServices := make(map[string]cwlogService) + m.EXPECT().TaskLogEvents(fmt.Sprintf(logGroupNamePattern, "mockProject", "mockEnv", "mockApp"), nil, gomock.Any()).Return(nil, errors.New("some error")) + cwlogServices["mockEnv"] = m + return cwlogServices + }, + + wantedError: fmt.Errorf("some error"), + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + b := &bytes.Buffer{} + appLogs := &appLogsOpts{ + appLogsVars: appLogsVars{ + follow: tc.inputFollow, + envName: tc.inputEnvName, + appName: tc.inputApplication, + shouldOutputJSON: tc.inputJSON, + GlobalOpts: &GlobalOpts{ + projectName: tc.inputProject, + }, + }, + initCwLogsSvc: func(*appLogsOpts, *archer.Environment) error { return nil }, + cwlogsSvc: tc.mockcwlogService(ctrl), + w: b, + } + + // WHEN + err := appLogs.Execute() + + // THEN + if tc.wantedError != nil { + require.EqualError(t, err, tc.wantedError.Error()) + } else { + require.Nil(t, err) + require.Equal(t, tc.wantedContent, b.String(), "expected output content match") + } + }) + } +} diff --git a/internal/pkg/cli/app_show.go b/internal/pkg/cli/app_show.go index ec5a2fbb305..dacb1655ae1 100644 --- a/internal/pkg/cli/app_show.go +++ b/internal/pkg/cli/app_show.go @@ -176,7 +176,7 @@ func (o *showAppOpts) askProject() error { return err } if len(projNames) == 0 { - return fmt.Errorf("no project found: run %s or %s into your workspace please", color.HighlightCode("project init"), color.HighlightCode("cd")) + return fmt.Errorf("no project found: run %s please", color.HighlightCode("project init")) } proj, err := o.prompt.SelectOne( applicationShowProjectNamePrompt, @@ -314,7 +314,7 @@ func BuildAppShowCmd() *cobra.Command { // The flags bound by viper are available to all sub-commands through viper.GetString({flagName}) cmd.Flags().StringVarP(&opts.appName, appFlag, appFlagShort, "", appFlagDescription) cmd.Flags().BoolVar(&opts.shouldOutputJSON, jsonFlag, false, jsonFlagDescription) - cmd.Flags().BoolVarP(&opts.shouldOutputResources, resourcesFlag, resourcesFlagShort, false, resourcesFlagDescription) + cmd.Flags().BoolVar(&opts.shouldOutputResources, resourcesFlag, false, resourcesFlagDescription) cmd.Flags().StringP(projectFlag, projectFlagShort, "" /* default */, projectFlagDescription) viper.BindPFlag(projectFlag, cmd.Flags().Lookup(projectFlag)) return cmd diff --git a/internal/pkg/cli/app_show_test.go b/internal/pkg/cli/app_show_test.go index 556a721d124..6da2e7e0be4 100644 --- a/internal/pkg/cli/app_show_test.go +++ b/internal/pkg/cli/app_show_test.go @@ -275,7 +275,7 @@ func TestAppShow_Ask(t *testing.T) { wantedProject: "my-project", wantedApp: "my-app", - wantedError: fmt.Errorf("no project found: run `project init` or `cd` into your workspace please"), + wantedError: fmt.Errorf("no project found: run `project init` please"), }, "returns error when fail to select project": { inputProject: "", diff --git a/internal/pkg/cli/cli.go b/internal/pkg/cli/cli.go index e9e96df02b4..613382a7320 100644 --- a/internal/pkg/cli/cli.go +++ b/internal/pkg/cli/cli.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer" + "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/cloudwatchlogs" "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr" "github.com/aws/amazon-ecs-cli-v2/internal/pkg/describe" "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/color" @@ -137,6 +138,11 @@ type ecrService interface { GetECRAuth() (ecr.Auth, error) } +type cwlogService interface { + TaskLogEvents(logGroupName string, stringTokens map[string]*string, opts ...cloudwatchlogs.GetLogEventsOpts) (*cloudwatchlogs.LogEventsOutput, error) + LogGroupExists(logGroupName string) (bool, error) +} + type dockerService interface { Build(uri, tag, path string) error Login(uri, username, password string) error diff --git a/internal/pkg/cli/flag.go b/internal/pkg/cli/flag.go index c05e9c3b51f..576cf183203 100644 --- a/internal/pkg/cli/flag.go +++ b/internal/pkg/cli/flag.go @@ -1,4 +1,4 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package cli @@ -19,6 +19,11 @@ const ( dockerFileFlag = "dockerfile" imageTagFlag = "tag" stackOutputDirFlag = "output-dir" + limitFlag = "limit" + followFlag = "follow" + sinceFlag = "since" + startTimeFlag = "start-time" + endTimeFlag = "end-time" prodEnvFlag = "prod" deployFlag = "deploy" resourcesFlag = "resources" @@ -46,7 +51,6 @@ const ( gitBranchFlagShort = "b" envsFlagShort = "e" pipelineFileFlagShort = "f" - resourcesFlagShort = "r" ) // Descriptions for flags. @@ -57,12 +61,20 @@ const ( appTypeFlagDescription = "Type of application to create." profileFlagDescription = "Name of the profile." yesFlagDescription = "Skips confirmation prompt." - jsonFlagDescription = "Output in JSON format." + jsonFlagDescription = "Optional. Outputs in JSON format." - dockerFileFlagDescription = "Path to the Dockerfile." - imageTagFlagDescription = `Optional. The application's image tag.` - stackOutputDirFlagDescription = "Optional. Writes the stack template and template configuration to a directory." - prodEnvFlagDescription = "If the environment contains production services." + dockerFileFlagDescription = "Path to the Dockerfile." + imageTagFlagDescription = `Optional. The application's image tag.` + stackOutputDirFlagDescription = "Optional. Writes the stack template and template configuration to a directory." + prodEnvFlagDescription = "If the environment contains production services." + limitFlagDescription = "Optional. The maximum number of log events returned." + followFlagDescription = "Optional. Specifies if the logs should be streamed." + sinceFlagDescription = `Optional. Only return logs newer than a relative duration like 5s, 2m, or 3h. +Defaults to all logs. Only one of start-time / since may be used.` + startTimeFlagDescription = `Optional. Only return logs after a specific date (RFC3339). +Defaults to all logs. Only one of start-time / since may be used.` + endTimeFlagDescription = `Optional. Only return logs before a specific date (RFC3339). +Defaults to all logs. Only one of end-time / follow may be used.` deployTestFlagDescription = `Deploy your application to a "test" environment.` githubURLFlagDescription = "GitHub repository URL for your application." githubAccessTokenFlagDescription = "GitHub personal access token for your repository." diff --git a/internal/pkg/cli/mocks/mock_cli.go b/internal/pkg/cli/mocks/mock_cli.go index fb996806687..71c79bd6d31 100644 --- a/internal/pkg/cli/mocks/mock_cli.go +++ b/internal/pkg/cli/mocks/mock_cli.go @@ -6,6 +6,7 @@ package mocks import ( archer "github.com/aws/amazon-ecs-cli-v2/internal/pkg/archer" + cloudwatchlogs "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/cloudwatchlogs" ecr "github.com/aws/amazon-ecs-cli-v2/internal/pkg/aws/ecr" describe "github.com/aws/amazon-ecs-cli-v2/internal/pkg/describe" command "github.com/aws/amazon-ecs-cli-v2/internal/pkg/term/command" @@ -343,6 +344,64 @@ func (mr *MockecrServiceMockRecorder) GetECRAuth() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetECRAuth", reflect.TypeOf((*MockecrService)(nil).GetECRAuth)) } +// MockcwlogService is a mock of cwlogService interface +type MockcwlogService struct { + ctrl *gomock.Controller + recorder *MockcwlogServiceMockRecorder +} + +// MockcwlogServiceMockRecorder is the mock recorder for MockcwlogService +type MockcwlogServiceMockRecorder struct { + mock *MockcwlogService +} + +// NewMockcwlogService creates a new mock instance +func NewMockcwlogService(ctrl *gomock.Controller) *MockcwlogService { + mock := &MockcwlogService{ctrl: ctrl} + mock.recorder = &MockcwlogServiceMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockcwlogService) EXPECT() *MockcwlogServiceMockRecorder { + return m.recorder +} + +// TaskLogEvents mocks base method +func (m *MockcwlogService) TaskLogEvents(logGroupName string, stringTokens map[string]*string, opts ...cloudwatchlogs.GetLogEventsOpts) (*cloudwatchlogs.LogEventsOutput, error) { + m.ctrl.T.Helper() + varargs := []interface{}{logGroupName, stringTokens} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "TaskLogEvents", varargs...) + ret0, _ := ret[0].(*cloudwatchlogs.LogEventsOutput) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// TaskLogEvents indicates an expected call of TaskLogEvents +func (mr *MockcwlogServiceMockRecorder) TaskLogEvents(logGroupName, stringTokens interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{logGroupName, stringTokens}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TaskLogEvents", reflect.TypeOf((*MockcwlogService)(nil).TaskLogEvents), varargs...) +} + +// LogGroupExists mocks base method +func (m *MockcwlogService) LogGroupExists(logGroupName string) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "LogGroupExists", logGroupName) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// LogGroupExists indicates an expected call of LogGroupExists +func (mr *MockcwlogServiceMockRecorder) LogGroupExists(logGroupName interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogGroupExists", reflect.TypeOf((*MockcwlogService)(nil).LogGroupExists), logGroupName) +} + // MockdockerService is a mock of dockerService interface type MockdockerService struct { ctrl *gomock.Controller diff --git a/internal/pkg/term/color/color.go b/internal/pkg/term/color/color.go index 155d2997d15..7a9a268da13 100644 --- a/internal/pkg/term/color/color.go +++ b/internal/pkg/term/color/color.go @@ -1,4 +1,4 @@ -// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 // Package color provides functionality to displayed colored text on the terminal. @@ -18,6 +18,7 @@ import ( var ( Grey = color.New(color.FgWhite) Red = color.New(color.FgHiRed) + Yellow = color.New(color.FgHiYellow) Cyan = color.New(color.FgCyan) HiCyan = color.New(color.FgHiCyan) Bold = color.New(color.Bold) diff --git a/templates/environment/cf.yml b/templates/environment/cf.yml index 4172cc4966e..7414e12f049 100644 --- a/templates/environment/cf.yml +++ b/templates/environment/cf.yml @@ -263,6 +263,7 @@ Resources: "logs:GetQueryResults", "logs:StartQuery", "logs:GetLogEvents", + "logs:DescribeLogStreams", "logs:StopQuery", "logs:TestMetricFilter", "logs:FilterLogEvents",