github.com/defang-io/defang/src@v0.0.0-20240505002154-bdf411911834/pkg/cli/client/byoc/clouds/stream.go (about) 1 package clouds 2 3 import ( 4 "context" 5 "encoding/json" 6 "io" 7 "strings" 8 "time" 9 10 "github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs/types" 11 "github.com/defang-io/defang/src/pkg" 12 "github.com/defang-io/defang/src/pkg/cli/client" 13 "github.com/defang-io/defang/src/pkg/clouds/aws/ecs" 14 "github.com/defang-io/defang/src/pkg/logs" 15 defangv1 "github.com/defang-io/defang/src/protos/io/defang/v1" 16 "google.golang.org/protobuf/types/known/timestamppb" 17 ) 18 19 // byocServerStream is a wrapper around awsecs.EventStream that implements connect-like ServerStream 20 type byocServerStream struct { 21 ctx context.Context 22 err error 23 errCh <-chan error 24 etag string 25 response *defangv1.TailResponse 26 service string 27 stream ecs.EventStream 28 } 29 30 func newByocServerStream(ctx context.Context, stream ecs.EventStream, etag, service string) *byocServerStream { 31 var errCh <-chan error 32 if errch, ok := stream.(hasErrCh); ok { 33 errCh = errch.Errs() 34 } 35 36 return &byocServerStream{ 37 ctx: ctx, 38 errCh: errCh, 39 etag: etag, 40 stream: stream, 41 service: service, 42 } 43 } 44 45 var _ client.ServerStream[defangv1.TailResponse] = (*byocServerStream)(nil) 46 47 func (bs *byocServerStream) Close() error { 48 return bs.stream.Close() 49 } 50 51 func (bs *byocServerStream) Err() error { 52 if bs.err == io.EOF { 53 return nil // same as the original gRPC/connect server stream 54 } 55 return annotateAwsError(bs.err) 56 } 57 58 func (bs *byocServerStream) Msg() *defangv1.TailResponse { 59 return bs.response 60 } 61 62 type hasErrCh interface { 63 Errs() <-chan error 64 } 65 66 func (bs *byocServerStream) Receive() bool { 67 select { 68 case e := <-bs.stream.Events(): // blocking 69 entries, err := bs.parseEvents(e) 70 if err != nil { 71 bs.err = err 72 return false 73 } 74 bs.response.Entries = entries 75 return true 76 77 case err := <-bs.errCh: // blocking (if not nil) 78 bs.err = err 79 return false // abort on first error? 80 81 case <-bs.ctx.Done(): // blocking (if not nil) 82 bs.err = context.Cause(bs.ctx) 83 return false 84 } 85 } 86 87 func (bs *byocServerStream) parseEvents(e types.StartLiveTailResponseStream) ([]*defangv1.LogEntry, error) { 88 events, err := ecs.GetLogEvents(e) 89 if err != nil { 90 return nil, err 91 } 92 bs.response = &defangv1.TailResponse{} 93 if len(events) == 0 { 94 // The original gRPC/connect server stream would never send an empty response. 95 // We could loop around the select, but returning an empty response updates the spinner. 96 return nil, nil 97 } 98 var record logs.FirelensMessage 99 parseFirelensRecords := false 100 // Get the Etag/Host/Service from the first event (should be the same for all events in this batch) 101 event := events[0] 102 if parts := strings.Split(*event.LogStreamName, "/"); len(parts) == 3 { 103 if strings.Contains(*event.LogGroupIdentifier, ":"+CdTaskPrefix) { 104 // These events are from the CD task: "crun/main/taskID" stream; we should detect stdout/stderr 105 bs.response.Etag = bs.etag // pass the etag filter below, but we already filtered the tail by taskID 106 bs.response.Host = "pulumi" 107 bs.response.Service = "cd" 108 } else { 109 // These events are from an awslogs service task: "tenant/service_etag/taskID" stream 110 bs.response.Host = parts[2] // TODO: figure out actual hostname/IP 111 parts = strings.Split(parts[1], "_") 112 if len(parts) != 2 || !pkg.IsValidRandomID(parts[1]) { 113 // skip, ignore sidecar logs (like route53-sidecar or fluentbit) 114 return nil, nil 115 } 116 service, etag := parts[0], parts[1] 117 bs.response.Etag = etag 118 bs.response.Service = service 119 } 120 } else if strings.Contains(*event.LogStreamName, "-firelens-") { 121 // These events are from the Firelens sidecar; try to parse the JSON 122 if err := json.Unmarshal([]byte(*event.Message), &record); err == nil { 123 bs.response.Etag = record.Etag 124 bs.response.Host = record.Host // TODO: use "kaniko" for kaniko logs 125 bs.response.Service = record.ContainerName // TODO: could be service_etag 126 parseFirelensRecords = true 127 } 128 } 129 if bs.etag != "" && bs.etag != bs.response.Etag { 130 return nil, nil // TODO: filter these out using the AWS StartLiveTail API 131 } 132 if bs.service != "" && bs.service != bs.response.Service { 133 return nil, nil // TODO: filter these out using the AWS StartLiveTail API 134 } 135 entries := make([]*defangv1.LogEntry, len(events)) 136 for i, event := range events { 137 stderr := false // TODO: detect somehow from source 138 message := *event.Message 139 if parseFirelensRecords { 140 if err := json.Unmarshal([]byte(message), &record); err == nil { 141 message = record.Log 142 if record.ContainerName == "kaniko" { 143 stderr = logs.IsLogrusError(message) 144 } else { 145 stderr = record.Source == logs.SourceStderr 146 } 147 } 148 } else if bs.response.Service == "cd" && strings.HasPrefix(message, " ** ") { 149 stderr = true 150 } 151 entries[i] = &defangv1.LogEntry{ 152 Message: message, 153 Stderr: stderr, 154 Timestamp: timestamppb.New(time.UnixMilli(*event.Timestamp)), 155 } 156 } 157 return entries, nil 158 }