github.com/mweagle/Sparta@v1.15.0/explore_views.go (about)

     1  package sparta
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io"
     8  	"io/ioutil"
     9  	"os"
    10  	"path/filepath"
    11  	"runtime"
    12  	"sort"
    13  	"strings"
    14  	"sync"
    15  	"time"
    16  
    17  	"github.com/aws/aws-sdk-go/aws"
    18  	"github.com/aws/aws-sdk-go/aws/session"
    19  	"github.com/aws/aws-sdk-go/service/cloudformation"
    20  	"github.com/aws/aws-sdk-go/service/lambda"
    21  	broadcast "github.com/dustin/go-broadcast"
    22  	"github.com/gdamore/tcell"
    23  	prettyjson "github.com/hokaccha/go-prettyjson"
    24  	spartaCWLogs "github.com/mweagle/Sparta/aws/cloudwatch/logs"
    25  	"github.com/pkg/errors"
    26  	"github.com/rivo/tview"
    27  	"github.com/sirupsen/logrus"
    28  )
    29  
    30  var (
    31  	progressEmoji        = []string{"🌍", "🌎", "🌏"}
    32  	windowsProgressEmoji = []string{"◐", "◓", "◑", "◒"}
    33  )
    34  
    35  ////////////////////////////////////////////////////////////////////////////////
    36  //
    37  // Settings
    38  
    39  var mu sync.Mutex
    40  
    41  const (
    42  	settingSelectedARN   = "functionARN"
    43  	settingSelectedEvent = "selectedEvent"
    44  )
    45  
    46  func settingsFile() string {
    47  	return filepath.Join(ScratchDirectory, "explore-settings.json")
    48  }
    49  func saveSetting(key string, value string) {
    50  	settingsMap := loadSettings()
    51  	settingsMap[string(key)] = value
    52  	output, outputErr := json.MarshalIndent(settingsMap, "", " ")
    53  	if outputErr != nil {
    54  		return
    55  	}
    56  	mu.Lock()
    57  	/* #nosec */
    58  	ioutil.WriteFile(settingsFile(), output, os.ModePerm)
    59  	mu.Unlock()
    60  }
    61  
    62  func loadSettings() map[string]string {
    63  	defaultSettings := make(map[string]string)
    64  	settingsFile := settingsFile()
    65  	mu.Lock()
    66  	/* #nosec */
    67  	bytes, bytesErr := ioutil.ReadFile(settingsFile)
    68  	mu.Unlock()
    69  	if bytesErr != nil {
    70  		return defaultSettings
    71  	}
    72  	/* #nosec */
    73  	json.Unmarshal(bytes, &defaultSettings)
    74  	return defaultSettings
    75  }
    76  
    77  // Settings
    78  //
    79  ////////////////////////////////////////////////////////////////////////////////
    80  
    81  func writePrettyString(writer io.Writer, input string) {
    82  	colorWriter := tview.ANSIWriter(writer)
    83  	var jsonData map[string]interface{}
    84  	jsonErr := json.Unmarshal([]byte(input), &jsonData)
    85  	if jsonErr == nil {
    86  		// pretty print it to colors...
    87  		prettyString, prettyStringErr := prettyjson.Marshal(jsonData)
    88  		if prettyStringErr == nil {
    89  			/* #nosec */
    90  			io.WriteString(colorWriter, string(prettyString))
    91  		} else {
    92  			/* #nosec */
    93  			io.WriteString(colorWriter, input)
    94  		}
    95  	} else {
    96  		/* #nosec */
    97  
    98  		io.WriteString(colorWriter, strings.TrimSpace(input))
    99  	}
   100  	/* #nosec */
   101  	io.WriteString(writer, "\n")
   102  }
   103  
   104  ////////////////////////////////////////////////////////////////////////////////
   105  //
   106  // Select the function to test
   107  //
   108  func newFunctionSelector(awsSession *session.Session,
   109  	stackResources []*cloudformation.StackResource,
   110  	app *tview.Application,
   111  	lambdaAWSInfos []*LambdaAWSInfo,
   112  	settings map[string]string,
   113  	onChangeBroadcaster broadcast.Broadcaster,
   114  	logger *logrus.Logger) (tview.Primitive, []tview.Primitive) {
   115  
   116  	lambdaARN := func(stackID string, logicalName string) string {
   117  		// stackID: arn:aws:cloudformation:us-west-2:123412341234:stack/MyHelloWorldStack-mweagle/54339e80-6686-11e8-90cd-503f20f2ad82
   118  		// lambdaARN: arn:aws:lambda:us-west-2:123412341234:function:MyHelloWorldStack-mweagle_Hello_World
   119  		stackParts := strings.Split(stackID, ":")
   120  		lambdaARNParts := []string{
   121  			"arn:aws:lambda:",
   122  			stackParts[3],
   123  			":",
   124  			stackParts[4],
   125  			":function:",
   126  			logicalName,
   127  		}
   128  		return strings.Join(lambdaARNParts, "")
   129  	}
   130  	// Ok, walk the resources and assemble all the ARNs for the lambda functions
   131  	lambdaFunctionARNs := []string{}
   132  	for _, eachResource := range stackResources {
   133  		if *eachResource.ResourceType == "AWS::Lambda::Function" {
   134  			logger.WithField("Resource", *eachResource.LogicalResourceId).Debug("Found provisioned Lambda function")
   135  			lambdaFunctionARNs = append(lambdaFunctionARNs, lambdaARN(*eachResource.StackId, *eachResource.PhysicalResourceId))
   136  		}
   137  	}
   138  	sort.Strings(lambdaFunctionARNs)
   139  	selectedARN := settings[settingSelectedARN]
   140  	selectedIndex := 0
   141  	for index, eachARN := range lambdaFunctionARNs {
   142  		if eachARN == selectedARN {
   143  			selectedIndex = index
   144  			break
   145  		}
   146  	}
   147  	dropdown := tview.NewDropDown().
   148  		SetCurrentOption(selectedIndex).
   149  		SetLabel("Function ARN: ").
   150  		SetOptions(lambdaFunctionARNs, nil)
   151  	dropdown.SetBorder(true).SetTitle("Select Function")
   152  
   153  	dropdownDoneFunc := func(key tcell.Key) {
   154  		selectedIndex, value := dropdown.GetCurrentOption()
   155  		if selectedIndex != -1 {
   156  			saveSetting(settingSelectedARN, value)
   157  			onChangeBroadcaster.Submit(value)
   158  		}
   159  	}
   160  	dropdown.SetDoneFunc(dropdownDoneFunc)
   161  	// Populate it...
   162  	dropdownDoneFunc(tcell.KeyEnter)
   163  	return dropdown, []tview.Primitive{dropdown}
   164  }
   165  
   166  ////////////////////////////////////////////////////////////////////////////////
   167  //
   168  // Select the event to use to invoke the function
   169  //
   170  func newEventInputSelector(awsSession *session.Session,
   171  	app *tview.Application,
   172  	lambdaAWSInfos []*LambdaAWSInfo,
   173  	settings map[string]string,
   174  	inputExtensionsFilters []string,
   175  	functionSelectedBroadcaster broadcast.Broadcaster,
   176  	logger *logrus.Logger) (tview.Primitive, []tview.Primitive) {
   177  
   178  	divider := strings.Repeat("━", 20)
   179  	activeFunction := ""
   180  	ch := make(chan interface{})
   181  	functionSelectedBroadcaster.Register(ch)
   182  	go func() {
   183  		//lint:ignore S1000 to make the check happy
   184  		for {
   185  			select {
   186  			case funcSelected := <-ch:
   187  				activeFunction = funcSelected.(string)
   188  			}
   189  		}
   190  	}()
   191  	lambdaSvc := lambda.New(awsSession)
   192  
   193  	// First walk the directory for anything that looks
   194  	// like a JSON file...
   195  	curDir, curDirErr := os.Getwd()
   196  	if curDirErr != nil {
   197  		return nil, nil
   198  	}
   199  	jsonFiles := []string{}
   200  	walkerFunc := func(path string, info os.FileInfo, err error) error {
   201  		for _, eachMatch := range inputExtensionsFilters {
   202  			if strings.HasSuffix(strings.ToLower(filepath.Ext(path)), eachMatch) &&
   203  				!strings.Contains(path, ScratchDirectory) {
   204  				relPath := strings.TrimPrefix(path, curDir)
   205  				jsonFiles = append(jsonFiles, relPath)
   206  				logger.WithField("RelativePath", relPath).Debug("Event file found")
   207  			}
   208  		}
   209  		return nil
   210  	}
   211  	walkErr := filepath.Walk(curDir, walkerFunc)
   212  	if walkErr != nil {
   213  		logger.WithError(walkErr).Error("Failed to find JSON files in directory: " + curDir)
   214  		return nil, nil
   215  	}
   216  	// Create all the views...
   217  	var selectedJSONData []byte
   218  	selectedInput := 0
   219  	eventSelected := settings[settingSelectedEvent]
   220  	for index, eachJSONFile := range jsonFiles {
   221  		if eventSelected == eachJSONFile {
   222  			selectedInput = index
   223  			break
   224  		}
   225  	}
   226  	eventDataView := tview.NewTextView().SetScrollable(true).SetDynamicColors(true)
   227  	dropdown := tview.NewDropDown().
   228  		SetCurrentOption(selectedInput).
   229  		SetLabel("Event: ").
   230  		SetOptions(jsonFiles, nil)
   231  
   232  	submitEventData := func(key tcell.Key) {
   233  		// What's the selected item?
   234  		selected, value := dropdown.GetCurrentOption()
   235  		if selected == -1 {
   236  			return
   237  		}
   238  		eventDataView.Clear()
   239  		// Save it...
   240  		saveSetting(settingSelectedEvent, value)
   241  		fullPath := curDir + value
   242  		/* #nosec */
   243  		jsonFile, jsonFileErr := ioutil.ReadFile(fullPath)
   244  		if jsonFileErr != nil {
   245  			writePrettyString(eventDataView, jsonFileErr.Error())
   246  		} else {
   247  			writePrettyString(eventDataView, string(jsonFile))
   248  		}
   249  		selectedJSONData = jsonFile
   250  	}
   251  	submitEventData(tcell.KeyEnter)
   252  	dropdown.SetDoneFunc(submitEventData)
   253  	submitButton := tview.NewButton("Submit")
   254  	submitButton.SetBackgroundColorActivated(tcell.ColorDarkGreen)
   255  	submitButton.SetLabelColorActivated(tcell.ColorWhite)
   256  	submitButton.SetBackgroundColor(tcell.ColorGray)
   257  	submitButton.SetLabelColor(tcell.ColorDarkGreen)
   258  	submitButton.SetSelectedFunc(func() {
   259  		if activeFunction == "" {
   260  			return
   261  		}
   262  		// Submit it to lambda
   263  		if activeFunction != "" {
   264  			lambdaInput := &lambda.InvokeInput{
   265  				FunctionName: aws.String(activeFunction),
   266  				Payload:      selectedJSONData,
   267  			}
   268  			invokeOutput, invokeOutputErr := lambdaSvc.Invoke(lambdaInput)
   269  			if invokeOutputErr != nil {
   270  				logger.WithFields(logrus.Fields{
   271  					"Error": invokeOutputErr,
   272  				}).Error("Failed to invoke Lambda function")
   273  			} else if invokeOutput.FunctionError != nil {
   274  				logger.WithFields(logrus.Fields{
   275  					"Error": invokeOutput.FunctionError,
   276  				}).Error("Lambda function produced an error")
   277  			} else {
   278  				var m interface{}
   279  
   280  				jsonErr := json.Unmarshal(invokeOutput.Payload, &m)
   281  				var responseData interface{}
   282  				if jsonErr == nil {
   283  					responseData = m
   284  				} else {
   285  					responseData = string(invokeOutput.Payload)
   286  				}
   287  				logger.WithFields(logrus.Fields{
   288  					"payload": responseData,
   289  				}).Info(divider + " AWS Lambda Response " + divider)
   290  			}
   291  		}
   292  	})
   293  
   294  	// Ok, so what we need now is a flexbox with a row,
   295  	flexRow := tview.NewFlex().SetDirection(tview.FlexColumn).
   296  		AddItem(dropdown, 0, 4, false).
   297  		AddItem(submitButton, 10, 1, false)
   298  
   299  	flex := tview.NewFlex().SetDirection(tview.FlexRow).
   300  		AddItem(flexRow, 1, 0, false).
   301  		AddItem(eventDataView, 0, 1, false)
   302  	flex.SetBorder(true).SetTitle("Select Event Input")
   303  	return flex, []tview.Primitive{dropdown, submitButton, eventDataView}
   304  }
   305  
   306  ////////////////////////////////////////////////////////////////////////////////
   307  //
   308  // Tail the cloudwatch logs for the active function
   309  //
   310  func newCloudWatchLogTailView(awsSession *session.Session,
   311  	app *tview.Application,
   312  	lambdaAWSInfos []*LambdaAWSInfo,
   313  	settings map[string]string,
   314  	functionSelectedBroadcaster broadcast.Broadcaster,
   315  	logger *logrus.Logger) (tview.Primitive, []tview.Primitive) {
   316  
   317  	osEmojiSet := progressEmoji
   318  	switch runtime.GOOS {
   319  	case "windows":
   320  		osEmojiSet = windowsProgressEmoji
   321  	}
   322  
   323  	// Great - so what we need to do is listen for both the selected function
   324  	// and a change in input. If we have values for both, then
   325  	// go ahead and issue the request. We can do this with two
   326  	// go-routines. The first one is just a go-routine that listens for cloudwatch log events
   327  	// for the selected function. TODO - filter
   328  	ch := make(chan interface{})
   329  	functionSelectedBroadcaster.Register(ch)
   330  
   331  	// So what we need here is a "Last event timestamp" entry and then the actual
   332  	// content...
   333  	cloudwatchLogInfoView := tview.NewTextView().SetDynamicColors(true)
   334  	cloudwatchLogInfoView.SetBorder(true)
   335  	logEventDataView := tview.NewTextView().SetDynamicColors(true)
   336  	logEventDataView.SetScrollable(true)
   337  	progressEmojiView := tview.NewTextView()
   338  
   339  	// Ok, for this we need two colums, with the first column
   340  	// being the
   341  	flexView := tview.NewFlex().SetDirection(tview.FlexRow).
   342  		AddItem(tview.NewFlex().SetDirection(tview.FlexColumn).
   343  			AddItem(cloudwatchLogInfoView, 0, 1, false), 3, 0, false).
   344  		AddItem(logEventDataView, 0, 1, false).
   345  		AddItem(progressEmojiView, 1, 0, false)
   346  	flexView.SetBorder(true).SetTitle("CloudWatch Logs")
   347  
   348  	updateCloudWatchLogInfoView := func(logGroupName string, latestTS int64) {
   349  		// Ref: https://godoc.org/github.com/rivo/tview#hdr-Colors
   350  		// Color tag definition: [<foreground>:<background>:<flags>]
   351  		cloudwatchLogInfoView.Clear()
   352  		ts := ""
   353  		if latestTS != 0 {
   354  			ts = time.Unix(latestTS, 0).Format(time.RFC3339)
   355  		}
   356  		msg := fmt.Sprintf("[-:-:b]LogGroupName[-:-:-]: [-:-:d]%s",
   357  			logGroupName)
   358  		if ts != "" {
   359  			msg += fmt.Sprintf(" ([-:-:b]Latest Event[-:-:-]: [-:-:d]%s)", ts)
   360  		}
   361  		writePrettyString(cloudwatchLogInfoView, msg)
   362  	}
   363  	updateCloudWatchLogInfoView("", 0)
   364  	// When we get a new function then
   365  	var selectedFunction string
   366  	go func() {
   367  		var doneChan chan bool
   368  		var ticker *time.Ticker
   369  		lastTime := int64(0)
   370  		animationIndex := 0
   371  
   372  		//lint:ignore S1000 to make the check happy
   373  		for {
   374  			select {
   375  			case funcSelected := <-ch:
   376  				if selectedFunction == funcSelected.(string) {
   377  					continue
   378  				}
   379  				selectedFunction = funcSelected.(string)
   380  				logEventDataView.Clear()
   381  				if doneChan != nil {
   382  					doneChan <- true
   383  					progressEmojiView.Clear()
   384  				}
   385  				if ticker != nil {
   386  					ticker.Stop()
   387  				}
   388  				ticker = time.NewTicker(time.Millisecond * 333)
   389  				lambdaARN := selectedFunction
   390  				lambdaParts := strings.Split(lambdaARN, ":")
   391  				logGroupName := fmt.Sprintf("/aws/lambda/%s", lambdaParts[len(lambdaParts)-1])
   392  				logger.WithField("Name", logGroupName).Debug("CloudWatch LogGroupName")
   393  
   394  				// Put this as the label in the view...
   395  				doneChan = make(chan bool)
   396  				messages := spartaCWLogs.TailWithContext(context.Background(),
   397  					doneChan,
   398  					awsSession,
   399  					logGroupName,
   400  					"",
   401  					logger)
   402  				// Go read it...
   403  				go func() {
   404  					for {
   405  						select {
   406  						case event := <-messages:
   407  							{
   408  								lastTime = *event.Timestamp / 1000
   409  								updateCloudWatchLogInfoView(logGroupName, lastTime)
   410  								writePrettyString(logEventDataView, *event.Message)
   411  								logger.WithField("EventID", *event.EventId).Debug("Event received")
   412  								logEventDataView.ScrollToEnd()
   413  								app.Draw()
   414  							}
   415  						case <-ticker.C:
   416  							/* #nosec */
   417  							animationIndex = (animationIndex + 1) % len(osEmojiSet)
   418  							progressEmojiView.Clear()
   419  							progressText := fmt.Sprintf("%s Waiting for events...", osEmojiSet[animationIndex])
   420  							/* #nosec */
   421  							io.WriteString(progressEmojiView, progressText)
   422  							// Update the other stuff
   423  							updateCloudWatchLogInfoView(logGroupName, lastTime)
   424  							app.Draw()
   425  						}
   426  					}
   427  				}()
   428  			}
   429  		}
   430  	}()
   431  	return flexView, []tview.Primitive{logEventDataView}
   432  }
   433  
   434  type colorizingFormatter struct {
   435  	TimestampFormat  string
   436  	DisableTimestamp bool
   437  	FieldMap         logrus.FieldMap
   438  }
   439  
   440  // Format renders a single log entry
   441  func (cf *colorizingFormatter) Format(entry *logrus.Entry) ([]byte, error) {
   442  	data := make(logrus.Fields, len(entry.Data)+3)
   443  	for k, v := range entry.Data {
   444  		switch v := v.(type) {
   445  		case error:
   446  			// Otherwise errors are ignored by `encoding/json`
   447  			// https://github.com/sirupsen/logrus/issues/137
   448  			data[k] = v.Error()
   449  		default:
   450  			data[k] = v
   451  		}
   452  	}
   453  	timestampFormat := cf.TimestampFormat
   454  	if timestampFormat == "" {
   455  		timestampFormat = time.RFC3339
   456  	}
   457  	if !cf.DisableTimestamp {
   458  		data[logrus.FieldKeyTime] = entry.Time.Format(timestampFormat)
   459  	}
   460  	data[logrus.FieldKeyMsg] = entry.Message
   461  	data[logrus.FieldKeyLevel] = entry.Level.String()
   462  	prettyString, prettyStringErr := prettyjson.Marshal(data)
   463  	if prettyStringErr != nil {
   464  		return nil, errors.Wrapf(prettyStringErr, "Failed to marshal fields to JSON")
   465  	}
   466  	return append(prettyString, '\n'), nil
   467  }
   468  
   469  ////////////////////////////////////////////////////////////////////////////////
   470  //
   471  // Redirect the logger to the log view
   472  //
   473  func newLogOutputView(awsSession *session.Session,
   474  	app *tview.Application,
   475  	lambdaAWSInfos []*LambdaAWSInfo,
   476  	settings map[string]string,
   477  	logger *logrus.Logger) (tview.Primitive, []tview.Primitive) {
   478  
   479  	// Log to JSON
   480  	logger.Formatter = &colorizingFormatter{}
   481  	logDataView := tview.NewTextView().
   482  		SetScrollable(true).
   483  		SetDynamicColors(true)
   484  	logDataView.SetChangedFunc(func() {
   485  		logDataView.ScrollToEnd()
   486  	})
   487  	logDataView.SetBorder(true).SetTitle("Output")
   488  
   489  	colorWriter := tview.ANSIWriter(logDataView)
   490  	logger.Out = colorWriter
   491  	return logDataView, []tview.Primitive{logDataView}
   492  }