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 }