github.com/kubeshop/testkube@v1.17.23/pkg/tcl/testworkflowstcl/testworkflowcontroller/logs.go (about)

     1  // Copyright 2024 Testkube.
     2  //
     3  // Licensed as a Testkube Pro file under the Testkube Community
     4  // License (the "License"); you may not use this file except in compliance with
     5  // the License. You may obtain a copy of the License at
     6  //
     7  //	https://github.com/kubeshop/testkube/blob/main/licenses/TCL.txt
     8  
     9  package testworkflowcontroller
    10  
    11  import (
    12  	"bufio"
    13  	"context"
    14  	"errors"
    15  	"io"
    16  	"regexp"
    17  	"strconv"
    18  	"strings"
    19  	"time"
    20  
    21  	errors2 "github.com/pkg/errors"
    22  	corev1 "k8s.io/api/core/v1"
    23  	"k8s.io/client-go/kubernetes"
    24  
    25  	"github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data"
    26  	"github.com/kubeshop/testkube/pkg/api/v1/testkube"
    27  	"github.com/kubeshop/testkube/pkg/utils"
    28  )
    29  
    30  type Comment struct {
    31  	Time   time.Time
    32  	Hint   *data.Instruction
    33  	Output *data.Instruction
    34  }
    35  
    36  type ContainerLog struct {
    37  	Time   time.Time
    38  	Log    []byte
    39  	Hint   *data.Instruction
    40  	Output *data.Instruction
    41  }
    42  
    43  type ContainerResult struct {
    44  	Status     testkube.TestWorkflowStepStatus
    45  	Details    string
    46  	ExitCode   int
    47  	FinishedAt time.Time
    48  }
    49  
    50  var UnknownContainerResult = ContainerResult{
    51  	Status:   testkube.ABORTED_TestWorkflowStepStatus,
    52  	ExitCode: -1,
    53  }
    54  
    55  func GetContainerResult(c corev1.ContainerStatus) ContainerResult {
    56  	if c.State.Waiting != nil {
    57  		return ContainerResult{Status: testkube.QUEUED_TestWorkflowStepStatus, ExitCode: -1}
    58  	}
    59  	if c.State.Running != nil {
    60  		return ContainerResult{Status: testkube.RUNNING_TestWorkflowStepStatus, ExitCode: -1}
    61  	}
    62  	re := regexp.MustCompile(`^([^,]*),(0|[1-9]\d*)$`)
    63  
    64  	// Workaround - GKE sends SIGKILL after the container is already terminated,
    65  	// and the pod gets stuck then.
    66  	if c.State.Terminated.Reason != "Completed" {
    67  		return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, Details: c.State.Terminated.Reason, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time}
    68  	}
    69  
    70  	msg := c.State.Terminated.Message
    71  	match := re.FindStringSubmatch(msg)
    72  	if match == nil {
    73  		return ContainerResult{Status: testkube.ABORTED_TestWorkflowStepStatus, ExitCode: -1, FinishedAt: c.State.Terminated.FinishedAt.Time}
    74  	}
    75  	status := testkube.TestWorkflowStepStatus(match[1])
    76  	exitCode, _ := strconv.Atoi(match[2])
    77  	if status == "" {
    78  		status = testkube.PASSED_TestWorkflowStepStatus
    79  	}
    80  	return ContainerResult{Status: status, ExitCode: exitCode, FinishedAt: c.State.Terminated.FinishedAt.Time}
    81  }
    82  
    83  func GetFinalContainerResult(ctx context.Context, pod Watcher[*corev1.Pod], containerName string) (ContainerResult, error) {
    84  	w := WatchContainerStatus(ctx, pod, containerName, 0)
    85  	stream := w.Stream(ctx)
    86  	defer w.Close()
    87  
    88  	for c := range stream.Channel() {
    89  		if c.Error != nil {
    90  			return UnknownContainerResult, c.Error
    91  		}
    92  		if c.Value.State.Terminated == nil {
    93  			continue
    94  		}
    95  		return GetContainerResult(c.Value), nil
    96  	}
    97  	return UnknownContainerResult, nil
    98  }
    99  
   100  var ErrNoStartedEvent = errors.New("started event not received")
   101  
   102  func WatchContainerPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string, cacheSize int, includePodWarnings bool) Watcher[*corev1.Event] {
   103  	w := newWatcher[*corev1.Event](ctx, cacheSize)
   104  	go func() {
   105  		events := WatchContainerEvents(ctx, podEvents, containerName, 0, includePodWarnings)
   106  		defer events.Close()
   107  		defer w.Close()
   108  
   109  		for ev := range events.Stream(ctx).Channel() {
   110  			if ev.Error != nil {
   111  				w.SendError(ev.Error)
   112  			} else {
   113  				w.SendValue(ev.Value)
   114  				if ev.Value.Reason == "Started" {
   115  					return
   116  				}
   117  			}
   118  		}
   119  	}()
   120  	return w
   121  }
   122  
   123  func WatchPodPreEvents(ctx context.Context, podEvents Watcher[*corev1.Event], cacheSize int) Watcher[*corev1.Event] {
   124  	w := newWatcher[*corev1.Event](ctx, cacheSize)
   125  	go func() {
   126  		defer w.Close()
   127  
   128  		for ev := range podEvents.Stream(w.ctx).Channel() {
   129  			if ev.Error != nil {
   130  				w.SendError(ev.Error)
   131  			} else {
   132  				w.SendValue(ev.Value)
   133  				if ev.Value.Reason == "Scheduled" {
   134  					return
   135  				}
   136  			}
   137  		}
   138  	}()
   139  	return w
   140  }
   141  
   142  func WaitUntilContainerIsStarted(ctx context.Context, podEvents Watcher[*corev1.Event], containerName string) error {
   143  	events := WatchContainerPreEvents(ctx, podEvents, containerName, 0, false)
   144  	defer events.Close()
   145  
   146  	for ev := range events.Stream(ctx).Channel() {
   147  		if ev.Error != nil {
   148  			return ev.Error
   149  		} else if ev.Value.Reason == "Started" {
   150  			return nil
   151  		}
   152  	}
   153  	return ErrNoStartedEvent
   154  }
   155  
   156  func WatchContainerLogs(ctx context.Context, clientSet kubernetes.Interface, podEvents Watcher[*corev1.Event], namespace, podName, containerName string) Watcher[ContainerLog] {
   157  	w := newWatcher[ContainerLog](ctx, 0)
   158  
   159  	go func() {
   160  		defer w.Close()
   161  
   162  		// Wait until "Started" event, to avoid calling logs on the
   163  		err := WaitUntilContainerIsStarted(ctx, podEvents, containerName)
   164  		if err != nil {
   165  			w.SendError(err)
   166  			return
   167  		}
   168  
   169  		// Create logs stream request
   170  		req := clientSet.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
   171  			Follow:     true,
   172  			Timestamps: true,
   173  			Container:  containerName,
   174  		})
   175  		var stream io.ReadCloser
   176  		for {
   177  			stream, err = req.Stream(ctx)
   178  			if err != nil {
   179  				// The container is not necessarily already started when Started event is received
   180  				if !strings.Contains(err.Error(), "is waiting to start") {
   181  					w.SendError(err)
   182  					return
   183  				}
   184  				continue
   185  			}
   186  			break
   187  		}
   188  
   189  		go func() {
   190  			<-w.Done()
   191  			_ = stream.Close()
   192  		}()
   193  
   194  		// Parse and return the logs
   195  		reader := bufio.NewReader(stream)
   196  		var tsPrefix, tmpTsPrefix []byte
   197  		isNewLine := false
   198  		isStarted := false
   199  		var ts, tmpTs time.Time
   200  		for {
   201  			var prepend []byte
   202  
   203  			// Read next timestamp
   204  			tmpTs, tmpTsPrefix, err = ReadTimestamp(reader)
   205  			if err == nil {
   206  				ts = tmpTs
   207  				tsPrefix = tmpTsPrefix
   208  			} else if err == io.EOF {
   209  				return
   210  			} else {
   211  				// Edge case: Kubernetes may send critical errors without timestamp (like ionotify)
   212  				if len(tmpTsPrefix) > 0 {
   213  					prepend = tmpTsPrefix
   214  				}
   215  				w.SendError(err)
   216  			}
   217  
   218  			// Check for the next part
   219  			line, err := utils.ReadLongLine(reader)
   220  			if len(prepend) > 0 {
   221  				line = append(prepend, line...)
   222  			}
   223  
   224  			// Process the received line
   225  			if len(line) > 0 {
   226  				hadComment := false
   227  				instruction, isHint, err := data.DetectInstruction(line)
   228  				if err == nil && instruction != nil {
   229  					isNewLine = false
   230  					hadComment = true
   231  					log := ContainerLog{Time: ts}
   232  					if isHint {
   233  						log.Hint = instruction
   234  					} else {
   235  						log.Output = instruction
   236  					}
   237  					w.SendValue(log)
   238  				}
   239  
   240  				// Append as regular log if expected
   241  				if !hadComment {
   242  					if isNewLine {
   243  						line = append(append([]byte("\n"), tsPrefix...), line...)
   244  					} else if !isStarted {
   245  						line = append(tsPrefix, line...)
   246  						isStarted = true
   247  					}
   248  					w.SendValue(ContainerLog{Time: ts, Log: line})
   249  					isNewLine = true
   250  				}
   251  			} else if isStarted {
   252  				w.SendValue(ContainerLog{Time: ts, Log: append([]byte("\n"), tsPrefix...)})
   253  			}
   254  
   255  			// Handle the error
   256  			if err != nil {
   257  				if err != io.EOF {
   258  					w.SendError(err)
   259  				}
   260  				return
   261  			}
   262  		}
   263  	}()
   264  
   265  	return w
   266  }
   267  
   268  func ReadTimestamp(reader *bufio.Reader) (time.Time, []byte, error) {
   269  	tsPrefix := make([]byte, 31) // 30 bytes for timestamp + 1 byte for space
   270  	count, err := io.ReadFull(reader, tsPrefix)
   271  	if err != nil {
   272  		return time.Time{}, nil, err
   273  	}
   274  	if count < 31 {
   275  		return time.Time{}, nil, io.EOF
   276  	}
   277  	ts, err := time.Parse(KubernetesLogTimeFormat, string(tsPrefix[0:30]))
   278  	if err != nil {
   279  		return time.Time{}, tsPrefix, errors2.Wrap(err, "parsing timestamp")
   280  	}
   281  	return ts, tsPrefix, nil
   282  }