github.com/kubeshop/testkube@v1.17.23/pkg/tcl/testworkflowstcl/testworkflowexecutor/executor.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 testworkflowexecutor 10 11 import ( 12 "bufio" 13 "context" 14 "io" 15 "sync" 16 "time" 17 18 "github.com/pkg/errors" 19 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 20 "k8s.io/client-go/kubernetes" 21 22 "github.com/kubeshop/testkube/cmd/tcl/testworkflow-init/data" 23 "github.com/kubeshop/testkube/pkg/api/v1/testkube" 24 "github.com/kubeshop/testkube/pkg/event" 25 "github.com/kubeshop/testkube/pkg/log" 26 "github.com/kubeshop/testkube/pkg/tcl/repositorytcl/testworkflow" 27 "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowcontroller" 28 "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowprocessor" 29 ) 30 31 //go:generate mockgen -destination=./mock_executor.go -package=testworkflowexecutor "github.com/kubeshop/testkube/pkg/tcl/testworkflowstcl/testworkflowexecutor" TestWorkflowExecutor 32 type TestWorkflowExecutor interface { 33 Schedule(bundle *testworkflowprocessor.Bundle, execution testkube.TestWorkflowExecution) 34 Control(ctx context.Context, execution testkube.TestWorkflowExecution) 35 Recover(ctx context.Context) 36 } 37 38 type executor struct { 39 emitter *event.Emitter 40 clientSet kubernetes.Interface 41 repository testworkflow.Repository 42 output testworkflow.OutputRepository 43 namespace string 44 } 45 46 func New(emitter *event.Emitter, clientSet kubernetes.Interface, repository testworkflow.Repository, output testworkflow.OutputRepository, namespace string) TestWorkflowExecutor { 47 return &executor{ 48 emitter: emitter, 49 clientSet: clientSet, 50 repository: repository, 51 output: output, 52 namespace: namespace, 53 } 54 } 55 56 func (e *executor) Schedule(bundle *testworkflowprocessor.Bundle, execution testkube.TestWorkflowExecution) { 57 // Inform about execution start 58 e.emitter.Notify(testkube.NewEventQueueTestWorkflow(&execution)) 59 60 // Deploy required resources 61 err := e.Deploy(context.Background(), bundle) 62 if err != nil { 63 e.handleFatalError(execution, err, time.Time{}) 64 return 65 } 66 67 // Start to control the results 68 go e.Control(context.Background(), execution) 69 } 70 71 func (e *executor) Deploy(ctx context.Context, bundle *testworkflowprocessor.Bundle) (err error) { 72 for _, item := range bundle.Secrets { 73 _, err = e.clientSet.CoreV1().Secrets(e.namespace).Create(ctx, &item, metav1.CreateOptions{}) 74 if err != nil { 75 return 76 } 77 } 78 for _, item := range bundle.ConfigMaps { 79 _, err = e.clientSet.CoreV1().ConfigMaps(e.namespace).Create(ctx, &item, metav1.CreateOptions{}) 80 if err != nil { 81 return 82 } 83 } 84 _, err = e.clientSet.BatchV1().Jobs(e.namespace).Create(ctx, &bundle.Job, metav1.CreateOptions{}) 85 return 86 } 87 88 func (e *executor) handleFatalError(execution testkube.TestWorkflowExecution, err error, ts time.Time) { 89 // Detect error type 90 isAborted := errors.Is(err, testworkflowcontroller.ErrJobAborted) 91 isTimeout := errors.Is(err, testworkflowcontroller.ErrJobTimeout) 92 93 // Build error timestamp, adjusting it for aborting job 94 if ts.IsZero() { 95 ts = time.Now() 96 if isAborted || isTimeout { 97 ts = ts.Truncate(testworkflowcontroller.JobRetrievalTimeout) 98 } 99 } 100 101 // Apply the expected result 102 execution.Result.Fatal(err, isAborted, ts) 103 err = e.repository.UpdateResult(context.Background(), execution.Id, execution.Result) 104 if err != nil { 105 log.DefaultLogger.Errorf("failed to save fatal error for execution %s: %v", execution.Id, err) 106 } 107 e.emitter.Notify(testkube.NewEventEndTestWorkflowFailed(&execution)) 108 go testworkflowcontroller.Cleanup(context.Background(), e.clientSet, e.namespace, execution.Id) 109 } 110 111 func (e *executor) Recover(ctx context.Context) { 112 list, err := e.repository.GetRunning(ctx) 113 if err != nil { 114 return 115 } 116 for _, execution := range list { 117 e.Control(context.Background(), execution) 118 } 119 } 120 121 func (e *executor) Control(ctx context.Context, execution testkube.TestWorkflowExecution) { 122 ctrl, err := testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) 123 if err != nil { 124 e.handleFatalError(execution, err, time.Time{}) 125 return 126 } 127 128 // Prepare stream for writing log 129 r, writer := io.Pipe() 130 reader := bufio.NewReader(r) 131 ref := "" 132 133 wg := sync.WaitGroup{} 134 wg.Add(1) 135 go func() { 136 defer wg.Done() 137 138 for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { 139 if v.Error != nil { 140 continue 141 } 142 if v.Value.Output != nil { 143 execution.Output = append(execution.Output, *testworkflowcontroller.InstructionToInternal(v.Value.Output)) 144 } else if v.Value.Result != nil { 145 execution.Result = v.Value.Result 146 if execution.Result.IsFinished() { 147 execution.StatusAt = execution.Result.FinishedAt 148 } 149 err := e.repository.UpdateResult(ctx, execution.Id, execution.Result) 150 if err != nil { 151 log.DefaultLogger.Error(errors.Wrap(err, "error saving test workflow execution result")) 152 } 153 } else { 154 if ref != v.Value.Ref { 155 ref = v.Value.Ref 156 _, err := writer.Write([]byte(data.SprintHint(ref, "start"))) 157 if err != nil { 158 log.DefaultLogger.Error(errors.Wrap(err, "saving log output signature")) 159 } 160 } 161 _, err := writer.Write([]byte(v.Value.Log)) 162 if err != nil { 163 log.DefaultLogger.Error(errors.Wrap(err, "saving log output content")) 164 } 165 } 166 } 167 168 // Try to gracefully handle abort 169 if execution.Result.FinishedAt.IsZero() { 170 // Handle container failure 171 abortedAt := time.Time{} 172 for _, v := range execution.Result.Steps { 173 if v.Status != nil && *v.Status == testkube.ABORTED_TestWorkflowStepStatus { 174 abortedAt = v.FinishedAt 175 break 176 } 177 } 178 if !abortedAt.IsZero() { 179 e.handleFatalError(execution, testworkflowcontroller.ErrJobAborted, abortedAt) 180 } else { 181 // Handle unknown state 182 ctrl, err = testworkflowcontroller.New(ctx, e.clientSet, e.namespace, execution.Id, execution.ScheduledAt) 183 if err == nil { 184 for v := range ctrl.Watch(ctx).Stream(ctx).Channel() { 185 if v.Error != nil || v.Value.Output == nil { 186 continue 187 } 188 189 execution.Result = v.Value.Result 190 if execution.Result.IsFinished() { 191 execution.StatusAt = execution.Result.FinishedAt 192 } 193 err := e.repository.UpdateResult(ctx, execution.Id, execution.Result) 194 if err != nil { 195 log.DefaultLogger.Error(errors.Wrap(err, "error saving test workflow execution result")) 196 } 197 } 198 } else { 199 e.handleFatalError(execution, err, time.Time{}) 200 } 201 } 202 } 203 204 err := writer.Close() 205 if err != nil { 206 log.DefaultLogger.Errorw("failed to close TestWorkflow log output stream", "id", execution.Id, "error", err) 207 } 208 209 // TODO: Consider AppendOutput ($push) instead 210 _ = e.repository.UpdateOutput(ctx, execution.Id, execution.Output) 211 if execution.Result.IsFinished() { 212 if execution.Result.IsPassed() { 213 e.emitter.Notify(testkube.NewEventEndTestWorkflowSuccess(&execution)) 214 } else if execution.Result.IsAborted() { 215 e.emitter.Notify(testkube.NewEventEndTestWorkflowAborted(&execution)) 216 } else { 217 e.emitter.Notify(testkube.NewEventEndTestWorkflowFailed(&execution)) 218 } 219 } 220 }() 221 222 // Stream the log into Minio 223 err = e.output.SaveLog(context.Background(), execution.Id, execution.Workflow.Name, reader) 224 if err != nil { 225 log.DefaultLogger.Errorw("failed to save TestWorkflow log output", "id", execution.Id, "error", err) 226 } 227 228 wg.Wait() 229 230 err = testworkflowcontroller.Cleanup(ctx, e.clientSet, e.namespace, execution.Id) 231 if err != nil { 232 log.DefaultLogger.Errorw("failed to cleanup TestWorkflow resources", "id", execution.Id, "error", err) 233 } 234 }