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  }