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 }