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 }