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  }