Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixedwidth during stream of CF events #204

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 168 additions & 86 deletions aws/tailers/stack_event.go
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
package awstailers

import (
"bufio"
"bytes"
"fmt"
"io"
"reflect"
"strconv"
"strings"
"text/tabwriter"
"time"

"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/cloudformation"
"github.com/aws/aws-sdk-go/service/configservice"
"github.com/fatih/color"
"github.com/wallix/awless/aws/services"
)

const (
StackEventLogicalID = "id"
StackEventTimestamp = "ts"
StackEventStatus = "status"
StackEventStatusReason = "reason"
StackEventType = "type"
StackEventFilterLogicalID = "id"
StackEventFilterTimestamp = "ts"
StackEventFilterStatus = "status"
StackEventFilterStatusReason = "reason"
StackEventFilterType = "type"
StackEventFilterPhysicalId = "physical-id"

// valid stack status codes
// http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-describing-stacks.html#w2ab2c15c15c17c11
Expand All @@ -42,10 +46,30 @@ type stackEventTailer struct {
cancelAfterTimeout bool
}

// Copy of cloudformation.StackEvent for futher string formating
type stackEvent struct {
*cloudformation.StackEvent
Timestamp *string `width:"20,5"`
ResourceStatus *string `width:"50,5"`
ResourceType *string `width:"45,5"`
LogicalResourceId *string `width:"25,5"`

PhysicalResourceId *string `width:"40,5"`
ResourceStatusReason *string `width:"40,5"`
EventId *string
}

var filtersMapping = map[string]string{
StackEventFilterLogicalID: "LogicalResourceId",
StackEventFilterTimestamp: "Timestamp",
StackEventFilterStatus: "ResourceStatus",
StackEventFilterStatusReason: "ResourceStatusReason",
StackEventFilterType: "ResourceType",
StackEventFilterPhysicalId: "PhysicalResourceId",
}

var DefaultStackEventFilters = []string{StackEventFilterTimestamp, StackEventFilterLogicalID, StackEventFilterType, StackEventFilterStatus}
var AllStackEventFilters = append(DefaultStackEventFilters, StackEventFilterStatusReason, StackEventFilterPhysicalId)

type stackEvents []stackEvent

func NewCloudformationEventsTailer(stackName string, nbEvents int, enableFollow bool, frequency time.Duration, f filters, timeout time.Duration, cancelAfterTimeout bool) *stackEventTailer {
Expand Down Expand Up @@ -74,17 +98,9 @@ func (t *stackEventTailer) Tail(w io.Writer) error {
return fmt.Errorf("invalid polling frequency: %s, must be greater than 5s", t.pollingFrequency)
}

tab := tabwriter.NewWriter(w, 8, 8, 8, '\t', 0)
tab.Write(t.filters.header())

w.Write(t.filters.header())
if !t.follow {
if err := t.displayLastEvents(cfn, tab); err != nil {
return err
}

tab.Flush()

return nil
return t.displayLastEvents(cfn, w)
}

isDeploying, err := t.isStackBeingDeployed(cfn)
Expand Down Expand Up @@ -118,16 +134,14 @@ func (t *stackEventTailer) Tail(w io.Writer) error {
return fmt.Errorf("Timeout (%s) reached. Exiting...", t.timeout.String())
}
case <-ticker.C:
if err := t.displayRelevantEvents(cfn, tab); err != nil {
if err := t.displayRelevantEvents(cfn, w); err != nil {
return err
}

tab.Flush()

if t.deploymentStatus.isFinished {
if len(t.deploymentStatus.failedEvents) > 0 {
var errBuf bytes.Buffer
var f filters = []string{StackEventLogicalID, StackEventType, StackEventStatus, StackEventStatusReason}
var f filters = []string{StackEventFilterLogicalID, StackEventFilterType, StackEventFilterStatus, StackEventFilterStatusReason}

if isTimeoutReached {
errBuf.WriteString("Update was cancelled because timeout has been reached and option 'Cancel On Timeout' enabled\n")
Expand All @@ -137,12 +151,10 @@ func (t *stackEventTailer) Tail(w io.Writer) error {

errBuf.WriteString("Failed events summary:\n")

// printing error events as a nice table
errTab := tabwriter.NewWriter(&errBuf, 25, 8, 0, '\t', 0)
errTab.Write(f.header())
t.deploymentStatus.failedEvents.printReverse(errTab, f)
errTab.Flush()

errBuf.Write(f.header())
writer := bufio.NewWriter(&errBuf)
t.deploymentStatus.failedEvents.printReverse(writer, f)
writer.Flush()
return fmt.Errorf(errBuf.String())
}
return nil
Expand Down Expand Up @@ -175,7 +187,7 @@ func (t *stackEventTailer) getLatestEvents(cfn *awsservices.Cloudformation) (sta
if t.lastEventID != nil && *e.EventId == *t.lastEventID {
return stEvents, nil
}
stEvents = append(stEvents, stackEvent{e})
stEvents = append(stEvents, NewStackEvent(e))
}

if resp.NextToken == nil {
Expand Down Expand Up @@ -233,7 +245,7 @@ func (t *stackEventTailer) getRelevantEvents(cfn *awsservices.Cloudformation) (s
}

for _, e := range resp.StackEvents {
event := stackEvent{e}
event := NewStackEvent(e)
// if lastEventID == nil then it's first run of this method
// if lastEventID == nil then it's not first run and print only new messages
if t.lastEventID != nil && *e.EventId == *t.lastEventID {
Expand Down Expand Up @@ -282,83 +294,53 @@ func (t *stackEventTailer) displayRelevantEvents(cfn *awsservices.Cloudformation
return events.printReverse(w, t.filters)
}

func coloredResourceStatus(str string) string {
func colorizeResourceStatus(str string) *string {
var c color.Attribute
switch {
case strings.HasSuffix(str, StackEventFailed),
str == cloudformation.StackStatusUpdateRollbackInProgress,
str == cloudformation.StackStatusRollbackInProgress:
return color.New(color.FgRed).SprintFunc()(str)
c = color.FgRed
case strings.HasSuffix(str, StackEventInProgress):
return color.New(color.FgYellow).SprintFunc()(str)
c = color.FgYellow
case strings.HasSuffix(str, StackEventComplete):
return color.New(color.FgGreen).SprintFunc()(str)
default:
return str
c = color.FgGreen
}

s := color.New(c).SprintFunc()(str)

return &s
}

func (e stackEvents) printReverse(w io.Writer, f filters) error {
for i := len(e) - 1; i >= 0; i-- {
w.Write(e[i].filter(f))
w.Write(e[i].format(f))
}

return nil
}

func (f filters) header() []byte {
var buf bytes.Buffer
for i, filter := range f {
switch filter {
case StackEventLogicalID:
buf.WriteString("Logical ID")
case StackEventTimestamp:
buf.WriteString("Timestamp")
case StackEventStatus:
buf.WriteString("Status")
case StackEventStatusReason:
buf.WriteString("Status Reason")
case StackEventType:
buf.WriteString("Type")
}

if i != len(f)-1 {
buf.WriteRune('\t')
}

//// TODO: bold text still shifts the columns, need to figure out whats wrong
// s := &stackEvent{
// Timestamp: func() *string { t := color.New(color.Bold).Sprintf("Timestamp"); return &t }(),
// ResourceStatus: func() *string { t := color.New(color.Bold).Sprintf("Status"); return &t }(),
// LogicalResourceId: func() *string { t := color.New(color.Bold).Sprintf("Logical ID"); return &t }(),
// PhysicalResourceId: func() *string { t := color.New(color.Bold).Sprintf("Physical ID"); return &t }(),
// ResourceStatusReason: func() *string { t := color.New(color.Bold).Sprintf("Status Reason"); return &t }(),
// ResourceType: func() *string { t := color.New(color.Bold).Sprintf("Type"); return &t }(),
// }

s := &stackEvent{
Timestamp: func() *string { t := "Timestamp"; return &t }(),
ResourceStatus: func() *string { t := "Status"; return &t }(),
LogicalResourceId: func() *string { t := "Logical ID"; return &t }(),
PhysicalResourceId: func() *string { t := "Physical ID"; return &t }(),
ResourceStatusReason: func() *string { t := "Status Reason"; return &t }(),
ResourceType: func() *string { t := "Type"; return &t }(),
}

// with "\n" formatted with bold, tabwriter somehow shift lines
// so we need to add "\n" after string being bolded
return []byte(color.New(color.Bold).Sprintf(buf.String()) + "\n")
}

func (e *stackEvent) filter(filters []string) (out []byte) {
var buf bytes.Buffer

for i, f := range filters {
switch {
case f == StackEventLogicalID && e.LogicalResourceId != nil:
buf.WriteString(*e.LogicalResourceId)
case f == StackEventTimestamp && e.Timestamp != nil:
buf.WriteString(e.Timestamp.Format(time.RFC3339))
case f == StackEventStatus && e.ResourceStatus != nil:
buf.WriteString(coloredResourceStatus(*e.ResourceStatus))
case f == StackEventStatusReason && e.ResourceStatusReason != nil:
buf.WriteString(*e.ResourceStatusReason)
case f == StackEventType && e.ResourceType != nil:
buf.WriteString(*e.ResourceType)
}

if i != len(filters)-1 {
buf.WriteRune('\t')
}

}

buf.WriteRune('\n')

return buf.Bytes()
return s.format(f)
}

func (s *stackEvent) isDeploymentStart() bool {
Expand All @@ -385,3 +367,103 @@ func (s *stackEventTailer) cancelStackUpdate(cfn *awsservices.Cloudformation) er
_, err := cfn.CancelUpdateStack(inp)
return err
}

func NewStackEvent(e *cloudformation.StackEvent) stackEvent {
return stackEvent{
Timestamp: func() *string { t := e.Timestamp.Format(time.RFC3339); return &t }(),
ResourceStatus: e.ResourceStatus,
ResourceType: e.ResourceType,
LogicalResourceId: e.LogicalResourceId,
PhysicalResourceId: e.PhysicalResourceId,
ResourceStatusReason: e.ResourceStatusReason,
EventId: e.EventId,
}
}

// Format reads the struct tag `width:"<width>,<space>"`
// further marshaling into structured field
func (s *stackEvent) format(fil filters) []byte {
tp := reflect.TypeOf(s).Elem()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would be possible to add table unit tests for that method which seems central? It would help the understanding, document a bit the look of the output, put non regression in place and allow future safe refactoring.

I do not have the answer, I am just wondering.

v := reflect.ValueOf(s).Elem()

if s.ResourceStatus != nil {
s.ResourceStatus = colorizeResourceStatus(*s.ResourceStatus)
}

buf := bytes.Buffer{}
var nextLine *stackEvent
for i, f := range fil {
field, ok := tp.FieldByName(filtersMapping[f])
if !ok {
continue
}
value := v.FieldByName(filtersMapping[f])

splt := strings.Split(field.Tag.Get("width"), ",")
if len(splt) != 2 {
continue
}

width, err := strconv.Atoi(splt[0])
if err != nil {
continue
}

space, err := strconv.Atoi(splt[1])
if err != nil {
continue
}

// no need of space in the last column
if i == len(fil)-1 {
width += space
space = 0
}

var v string
if !value.IsNil() {
v = value.Elem().String()
}

// handle coloring
// if string starts with "\x1b" then it is colored
if strings.HasPrefix(v, "\x1b") {
// color adds additional length to the string
// which is not displayed in the console
// and results in text shift
// so we need to increase column width a bit
// colored string looks like: "\x1b[31mText\x1b[0m"
// TODO: looks like this doesn't helps, if one line has the
// more then one colored column (like header)
width += strings.Index(v, "m") + 1 + len("\x1b[0m")
}

if len(v) > width {
if nextLine == nil {
nextLine = &stackEvent{}
}
nv := reflect.ValueOf(nextLine).Elem()
nv.FieldByName(field.Name).Set(reflect.ValueOf(aws.String(v[width:])))
v = v[:width]
}

buf.WriteString(v)
// fil the rest of the line space with " "
buf.WriteString(createSpaces(width + space - len(v)))
}

buf.WriteRune('\n')
if nextLine != nil {
buf.Write(nextLine.format(fil))
}
return buf.Bytes()
}

func createSpaces(n int) string {
var buf = bytes.Buffer{}
for i := 0; i < n; i++ {
buf.WriteString(" ")
}

return buf.String()
}
10 changes: 3 additions & 7 deletions commands/tail.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package commands
import (
"fmt"
"os"
"strings"
"time"

"github.com/spf13/cobra"
Expand All @@ -42,13 +43,8 @@ func init() {
tailCmd.AddCommand(scalingActivitiesCmd)

stackEventsCmd.PersistentFlags().StringArrayVar(&stackEventsFilters, "filters",
[]string{awstailers.StackEventTimestamp, awstailers.StackEventLogicalID, awstailers.StackEventType, awstailers.StackEventStatus},
fmt.Sprintf("Filter the output columns. Valid filters: %s, %s, %s, %s, %s",
awstailers.StackEventLogicalID,
awstailers.StackEventStatus,
awstailers.StackEventStatusReason,
awstailers.StackEventTimestamp,
awstailers.StackEventType))
awstailers.DefaultStackEventFilters,
fmt.Sprintf("Filter the output columns. Valid filters: %s", strings.Join(awstailers.AllStackEventFilters, ",")))

stackEventsCmd.PersistentFlags().BoolVar(&cancelStackUpdateAfterTimeout, "cancel-on-timeout", false, "Cancel stack update when timeout is reached, use with 'timeout' flag")
stackEventsCmd.PersistentFlags().DurationVar(&stackEventsTailTimeout, "timeout", time.Duration(1*time.Hour), "Time to wait for stack update to complete, use with 'follow' flag")
Expand Down