github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/cli/tail.go (about)

     1  package cli
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"os"
     8  	"regexp"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/bufbuild/connect-go"
    13  	"github.com/defang-io/defang/src/pkg"
    14  	"github.com/defang-io/defang/src/pkg/cli/client"
    15  	"github.com/defang-io/defang/src/pkg/spinner"
    16  	"github.com/defang-io/defang/src/pkg/term"
    17  	defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1"
    18  	"github.com/muesli/termenv"
    19  	"google.golang.org/protobuf/types/known/timestamppb"
    20  )
    21  
    22  const (
    23  	ansiCyan      = "\033[36m"
    24  	ansiReset     = "\033[0m"
    25  	replaceString = ansiCyan + "$0" + ansiReset
    26  	RFC3339Micro  = "2006-01-02T15:04:05.000000Z07:00" // like RFC3339Nano but with 6 digits of precision
    27  )
    28  
    29  var (
    30  	colorKeyRegex = regexp.MustCompile(`"(?:\\["\\/bfnrt]|[^\x00-\x1f"\\]|\\u[0-9a-fA-F]{4})*"\s*:|[^\x00-\x20"=&?]+=`) // handles JSON, logfmt, and query params
    31  	DoVerbose     = false
    32  )
    33  
    34  type P = client.Property // shorthand for tracking properties
    35  
    36  // ParseTimeOrDuration parses a time string or duration string (e.g. 1h30m) and returns a time.Time.
    37  // At a minimum, this function supports RFC3339Nano, Go durations, and our own TimestampFormat (local).
    38  func ParseTimeOrDuration(str string) (time.Time, error) {
    39  	if strings.ContainsAny(str, "TZ") {
    40  		return time.Parse(time.RFC3339Nano, str)
    41  	}
    42  	if strings.Contains(str, ":") {
    43  		local, err := time.ParseInLocation("15:04:05.999999", str, time.Local)
    44  		if err != nil {
    45  			return time.Time{}, err
    46  		}
    47  		// Replace the year, month, and day of t with today's date
    48  		now := time.Now().Local()
    49  		sincet := time.Date(now.Year(), now.Month(), now.Day(), local.Hour(), local.Minute(), local.Second(), local.Nanosecond(), local.Location())
    50  		if sincet.After(now) {
    51  			sincet = sincet.AddDate(0, 0, -1) // yesterday; subtract 1 day
    52  		}
    53  		return sincet, nil
    54  	}
    55  	dur, err := time.ParseDuration(str)
    56  	if err != nil {
    57  		return time.Time{}, err
    58  	}
    59  	return time.Now().Add(-dur), nil // - because we want to go back in time
    60  }
    61  
    62  type CancelError struct {
    63  	Service string
    64  	Etag    string
    65  	Last    time.Time
    66  	error
    67  }
    68  
    69  func (cerr *CancelError) Error() string {
    70  	cmd := "tail --since " + cerr.Last.UTC().Format(time.RFC3339Nano)
    71  	if cerr.Service != "" {
    72  		cmd += " --name " + cerr.Service
    73  	}
    74  	if cerr.Etag != "" {
    75  		cmd += " --etag " + cerr.Etag
    76  	}
    77  	if DoVerbose {
    78  		cmd += " --verbose"
    79  	}
    80  	return cmd
    81  }
    82  
    83  func (cerr *CancelError) Unwrap() error {
    84  	return cerr.error
    85  }
    86  
    87  func Tail(ctx context.Context, client client.Client, service, etag string, since time.Time, raw bool) error {
    88  	if service != "" {
    89  		service = NormalizeServiceName(service)
    90  		// Show a warning if the service doesn't exist (yet);; TODO: could do fuzzy matching and suggest alternatives
    91  		if _, err := client.Get(ctx, &defangv1.ServiceID{Name: service}); err != nil {
    92  			switch connect.CodeOf(err) {
    93  			case connect.CodeNotFound:
    94  				term.Warn(" ! Service does not exist (yet):", service)
    95  			case connect.CodeUnknown:
    96  				// Ignore unknown (nil) errors
    97  			default:
    98  				term.Warn(" !", err)
    99  			}
   100  		}
   101  	}
   102  
   103  	if DoDryRun {
   104  		return ErrDryRun
   105  	}
   106  
   107  	ctx, cancel := context.WithCancel(ctx)
   108  
   109  	serverStream, err := client.Tail(ctx, &defangv1.TailRequest{Service: service, Etag: etag, Since: timestamppb.New(since)})
   110  	if err != nil {
   111  		return err
   112  	}
   113  	defer serverStream.Close() // this works because it takes a pointer receiver
   114  
   115  	spin := spinner.New()
   116  	doSpinner := !raw && term.CanColor && term.IsTerminal
   117  
   118  	if term.IsTerminal && !raw {
   119  		if doSpinner {
   120  			term.Stdout.HideCursor()
   121  			defer term.Stdout.ShowCursor()
   122  		}
   123  
   124  		if !DoVerbose {
   125  			term.Info(" * Press V to toggle verbose mode")
   126  			oldState, err := term.MakeUnbuf(int(os.Stdin.Fd()))
   127  			if err != nil {
   128  				return err
   129  			}
   130  			defer term.Restore(int(os.Stdin.Fd()), oldState)
   131  
   132  			input := term.NewNonBlockingStdin()
   133  			defer input.Close() // abort the read
   134  			go func() {
   135  				var b [1]byte
   136  				for {
   137  					if _, err := input.Read(b[:]); err != nil {
   138  						return // exit goroutine
   139  					}
   140  					switch b[0] {
   141  					case 3: // Ctrl-C
   142  						cancel()
   143  					case 10, 13: // Enter or Return
   144  						fmt.Println(" ") // empty line, but overwrite the spinner
   145  					case 'v', 'V':
   146  						verbose := !DoVerbose
   147  						DoVerbose = verbose
   148  						modeStr := "off"
   149  						if verbose {
   150  							modeStr = "on"
   151  						}
   152  						term.Info(" * Verbose mode", modeStr)
   153  						go client.Track("Verbose Toggled", P{"verbose", verbose})
   154  					}
   155  				}
   156  			}()
   157  		}
   158  	}
   159  
   160  	skipDuplicate := false
   161  	for {
   162  		if !serverStream.Receive() {
   163  			if errors.Is(serverStream.Err(), context.Canceled) {
   164  				return &CancelError{Service: service, Etag: etag, Last: since, error: serverStream.Err()}
   165  			}
   166  
   167  			// TODO: detect ALB timeout (504) or Fabric restart and reconnect automatically
   168  			code := connect.CodeOf(serverStream.Err())
   169  			// Reconnect on Error: internal: stream error: stream ID 5; INTERNAL_ERROR; received from peer
   170  			if code == connect.CodeUnavailable || (code == connect.CodeInternal && !connect.IsWireError(serverStream.Err())) {
   171  				term.Debug(" - Disconnected:", serverStream.Err())
   172  				if !raw {
   173  					term.Fprint(term.Stderr, term.WarnColor, " ! Reconnecting...\r") // overwritten below
   174  				}
   175  				time.Sleep(time.Second)
   176  				serverStream, err = client.Tail(ctx, &defangv1.TailRequest{Service: service, Etag: etag, Since: timestamppb.New(since)})
   177  				if err != nil {
   178  					term.Debug(" - Reconnect failed:", err)
   179  					return err
   180  				}
   181  				if !raw {
   182  					term.Fprint(term.Stderr, term.WarnColor, " ! Reconnected!   \r") // overwritten with logs
   183  				}
   184  				skipDuplicate = true
   185  				continue
   186  			}
   187  
   188  			return serverStream.Err() // returns nil on EOF
   189  		}
   190  		msg := serverStream.Msg()
   191  
   192  		// Show a spinner if we're not in raw mode and have a TTY
   193  		if doSpinner {
   194  			fmt.Print(spin.Next())
   195  		}
   196  
   197  		// HACK: skip noisy CI/CD logs (except errors)
   198  		isInternal := msg.Service == "cd" || msg.Service == "ci" || msg.Service == "kaniko" || msg.Service == "fabric" || msg.Host == "kaniko" || msg.Host == "fabric"
   199  		onlyErrors := !DoVerbose && isInternal
   200  		for _, e := range msg.Entries {
   201  			if onlyErrors && !e.Stderr {
   202  				continue
   203  			}
   204  
   205  			ts := e.Timestamp.AsTime()
   206  			if skipDuplicate && ts.Equal(since) {
   207  				skipDuplicate = false
   208  				continue
   209  			}
   210  			if ts.After(since) {
   211  				since = ts
   212  			}
   213  
   214  			if raw {
   215  				out := term.Stdout
   216  				if e.Stderr {
   217  					out = term.Stderr
   218  				}
   219  				fmt.Fprintln(out, e.Message) // TODO: trim trailing newline because we're already printing one?
   220  				continue
   221  			}
   222  
   223  			// Replace service progress messages with our own spinner
   224  			if doSpinner && isProgressDot(e.Message) {
   225  				continue
   226  			}
   227  
   228  			tsString := ts.Local().Format(RFC3339Micro)
   229  			tsColor := termenv.ANSIWhite
   230  			if e.Stderr {
   231  				tsColor = termenv.ANSIBrightRed
   232  			}
   233  			var prefixLen int
   234  			trimmed := strings.TrimRight(e.Message, "\t\r\n ")
   235  			for i, line := range strings.Split(trimmed, "\n") {
   236  				if i == 0 {
   237  					prefixLen, _ = term.Print(tsColor, tsString, " ")
   238  					if etag == "" {
   239  						l, _ := term.Print(termenv.ANSIYellow, msg.Etag, " ")
   240  						prefixLen += l
   241  					}
   242  					if service == "" {
   243  						l, _ := term.Print(termenv.ANSIGreen, msg.Service, " ")
   244  						prefixLen += l
   245  					}
   246  					if DoVerbose {
   247  						l, _ := term.Print(termenv.ANSIMagenta, msg.Host, " ")
   248  						prefixLen += l
   249  					}
   250  				} else {
   251  					fmt.Print(strings.Repeat(" ", prefixLen))
   252  				}
   253  				if term.CanColor {
   254  					if !strings.Contains(line, "\033[") {
   255  						line = colorKeyRegex.ReplaceAllString(line, replaceString) // add some color
   256  					}
   257  					term.Stdout.Reset()
   258  				} else {
   259  					line = pkg.StripAnsi(line)
   260  				}
   261  				fmt.Println(line)
   262  			}
   263  		}
   264  	}
   265  }
   266  
   267  func isProgressDot(line string) bool {
   268  	if len(line) <= 1 {
   269  		return true
   270  	}
   271  	stripped := pkg.StripAnsi(line)
   272  	for _, r := range stripped {
   273  		if r != '.' {
   274  			return false
   275  		}
   276  	}
   277  	return true
   278  }