github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/control/controlexecute/control_run.go (about)

     1  package controlexecute
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"log"
     7  	"sync"
     8  	"time"
     9  
    10  	typehelpers "github.com/turbot/go-kit/types"
    11  	"github.com/turbot/steampipe-plugin-sdk/v5/grpc"
    12  	"github.com/turbot/steampipe/pkg/constants"
    13  	"github.com/turbot/steampipe/pkg/control/controlstatus"
    14  	"github.com/turbot/steampipe/pkg/dashboard/dashboardtypes"
    15  	"github.com/turbot/steampipe/pkg/db/db_common"
    16  	"github.com/turbot/steampipe/pkg/error_helpers"
    17  	"github.com/turbot/steampipe/pkg/query/queryresult"
    18  	"github.com/turbot/steampipe/pkg/statushooks"
    19  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    20  	"github.com/turbot/steampipe/pkg/utils"
    21  )
    22  
    23  // ControlRun is a struct representing the execution of a control run. It will contain one or more result items (i.e. for one or more resources).
    24  type ControlRun struct {
    25  	// properties from control
    26  	ControlId     string            `json:"-"`
    27  	FullName      string            `json:"name"`
    28  	Title         string            `json:"title,omitempty"`
    29  	Description   string            `json:"description,omitempty"`
    30  	Documentation string            `json:"documentation,omitempty"`
    31  	Tags          map[string]string `json:"tags,omitempty"`
    32  	Display       string            `json:"display,omitempty"`
    33  	Type          string            `json:"display_type,omitempty"`
    34  
    35  	// this will be serialised under 'properties'
    36  	Severity string `json:"-"`
    37  
    38  	// "control"
    39  	NodeType string `json:"panel_type"`
    40  
    41  	// the control being run
    42  	Control *modconfig.Control `json:"properties,omitempty"`
    43  	// control summary
    44  	Summary   *controlstatus.StatusSummary `json:"summary"`
    45  	RunStatus dashboardtypes.RunStatus     `json:"status"`
    46  	// result rows
    47  	Rows ResultRows `json:"-"`
    48  
    49  	// the results in snapshot format
    50  	Data *dashboardtypes.LeafData `json:"data"`
    51  
    52  	// a list of distinct dimension keys from the results of this control
    53  	DimensionKeys []string `json:"-"`
    54  
    55  	// execution duration
    56  	Duration time.Duration `json:"-"`
    57  	// parent result group
    58  	Group *ResultGroup `json:"-"`
    59  	// execution tree
    60  	Tree *ExecutionTree `json:"-"`
    61  	// save run error as string for JSON export
    62  	RunErrorString string `json:"error,omitempty"`
    63  	runError       error
    64  	// the query result stream
    65  	queryResult *queryresult.Result
    66  	rowMap      map[string]ResultRows
    67  	stateLock   sync.Mutex
    68  	doneChan    chan bool
    69  	attempts    int
    70  }
    71  
    72  func NewControlRun(control *modconfig.Control, group *ResultGroup, executionTree *ExecutionTree) *ControlRun {
    73  	controlId := control.Name()
    74  
    75  	// only show qualified control names for controls from dependent mods
    76  	if control.Mod.Name() == executionTree.Workspace.Mod.Name() {
    77  		controlId = control.UnqualifiedName
    78  	}
    79  
    80  	res := &ControlRun{
    81  		Control:       control,
    82  		ControlId:     controlId,
    83  		FullName:      control.Name(),
    84  		Description:   control.GetDescription(),
    85  		Documentation: control.GetDocumentation(),
    86  		Tags:          control.GetTags(),
    87  		Display:       control.GetDisplay(),
    88  		Type:          control.GetType(),
    89  
    90  		Severity:  typehelpers.SafeString(control.Severity),
    91  		Title:     typehelpers.SafeString(control.Title),
    92  		rowMap:    make(map[string]ResultRows),
    93  		Summary:   &controlstatus.StatusSummary{},
    94  		Tree:      executionTree,
    95  		RunStatus: dashboardtypes.RunInitialized,
    96  
    97  		Group:    group,
    98  		NodeType: modconfig.BlockTypeControl,
    99  		doneChan: make(chan bool, 1),
   100  	}
   101  	return res
   102  }
   103  
   104  // GetControlId implements ControlRunStatusProvider
   105  func (r *ControlRun) GetControlId() string {
   106  	r.stateLock.Lock()
   107  	defer r.stateLock.Unlock()
   108  	return r.ControlId
   109  }
   110  
   111  // GetRunStatus implements ControlRunStatusProvider
   112  func (r *ControlRun) GetRunStatus() dashboardtypes.RunStatus {
   113  	r.stateLock.Lock()
   114  	defer r.stateLock.Unlock()
   115  	return r.RunStatus
   116  }
   117  
   118  // GetStatusSummary implements ControlRunStatusProvider
   119  func (r *ControlRun) GetStatusSummary() *controlstatus.StatusSummary {
   120  	r.stateLock.Lock()
   121  	defer r.stateLock.Unlock()
   122  	return r.Summary
   123  }
   124  
   125  func (r *ControlRun) Finished() bool {
   126  	return r.GetRunStatus().IsFinished()
   127  }
   128  
   129  // MatchTag returns the value corresponding to the input key. Returns 'false' if not found
   130  func (r *ControlRun) MatchTag(key string, value string) bool {
   131  	val, found := r.Control.GetTags()[key]
   132  	return found && (val == value)
   133  }
   134  
   135  func (r *ControlRun) GetError() error {
   136  	return r.runError
   137  }
   138  
   139  // IsSnapshotPanel implements SnapshotPanel
   140  func (*ControlRun) IsSnapshotPanel() {}
   141  
   142  // IsExecutionTreeNode implements ExecutionTreeNode
   143  func (*ControlRun) IsExecutionTreeNode() {}
   144  
   145  // GetChildren implements ExecutionTreeNode
   146  func (*ControlRun) GetChildren() []ExecutionTreeNode { return nil }
   147  
   148  // GetName implements ExecutionTreeNode
   149  func (r *ControlRun) GetName() string { return r.Control.Name() }
   150  
   151  // AsTreeNode implements ExecutionTreeNode
   152  func (r *ControlRun) AsTreeNode() *dashboardtypes.SnapshotTreeNode {
   153  	res := &dashboardtypes.SnapshotTreeNode{
   154  		Name:     r.Control.Name(),
   155  		NodeType: r.NodeType,
   156  	}
   157  	return res
   158  }
   159  
   160  func (r *ControlRun) setError(ctx context.Context, err error) {
   161  	if err == nil {
   162  		return
   163  	}
   164  	if r.runError == context.DeadlineExceeded {
   165  		r.runError = fmt.Errorf("control execution timed out")
   166  	} else {
   167  		r.runError = error_helpers.TransformErrorToSteampipe(err)
   168  	}
   169  	r.RunErrorString = r.runError.Error()
   170  	// update error count
   171  	r.Summary.Error++
   172  	if error_helpers.IsContextCancelledError(err) {
   173  		r.setRunStatus(ctx, dashboardtypes.RunCanceled)
   174  	} else {
   175  		r.setRunStatus(ctx, dashboardtypes.RunError)
   176  	}
   177  }
   178  
   179  func (r *ControlRun) skip(ctx context.Context) {
   180  	r.setRunStatus(ctx, dashboardtypes.RunComplete)
   181  }
   182  
   183  func (r *ControlRun) execute(ctx context.Context, client db_common.Client) {
   184  	utils.LogTime("ControlRun.execute start")
   185  	defer utils.LogTime("ControlRun.execute end")
   186  
   187  	log.Printf("[TRACE] begin ControlRun.Start: %s\n", r.Control.Name())
   188  	defer log.Printf("[TRACE] end ControlRun.Start: %s\n", r.Control.Name())
   189  
   190  	control := r.Control
   191  
   192  	startTime := time.Now()
   193  
   194  	// function to cleanup and update status after control run completion
   195  	defer func() {
   196  		// update the result group status with our status - this will be passed all the way up the execution tree
   197  		r.Group.updateSummary(r.Summary)
   198  		if len(r.Severity) != 0 {
   199  			r.Group.updateSeverityCounts(r.Severity, r.Summary)
   200  		}
   201  		r.Duration = time.Since(startTime)
   202  		if r.Group != nil {
   203  			r.Group.onChildDone()
   204  		}
   205  		log.Printf("[TRACE] finishing with concurrency, %s, , %d\n", r.Control.Name(), r.Tree.Progress.Executing)
   206  	}()
   207  
   208  	// get a db connection
   209  	sessionResult := r.acquireSession(ctx, client)
   210  	if sessionResult.Error != nil {
   211  		if !error_helpers.IsCancelledError(sessionResult.Error) {
   212  			log.Printf("[TRACE] controlRun %s execute failed to acquire session: %s", r.ControlId, sessionResult.Error)
   213  			sessionResult.Error = fmt.Errorf("error acquiring database connection, %s", sessionResult.Error.Error())
   214  			r.setError(ctx, sessionResult.Error)
   215  		}
   216  		return
   217  	}
   218  
   219  	dbSession := sessionResult.Session
   220  	defer func() {
   221  		// do this in a closure, otherwise the argument will not get evaluated during calltime
   222  		dbSession.Close(error_helpers.IsContextCanceled(ctx))
   223  	}()
   224  
   225  	// set our status
   226  	r.RunStatus = dashboardtypes.RunRunning
   227  
   228  	// update the current running control in the Progress renderer
   229  	r.Tree.Progress.OnControlStart(ctx, r)
   230  	defer func() {
   231  		// update Progress
   232  		if r.GetRunStatus() == dashboardtypes.RunError {
   233  			r.Tree.Progress.OnControlError(ctx, r)
   234  		} else {
   235  			r.Tree.Progress.OnControlComplete(ctx, r)
   236  		}
   237  	}()
   238  
   239  	// resolve the control query
   240  	resolvedQuery, err := r.resolveControlQuery(control)
   241  	if err != nil {
   242  		r.setError(ctx, err)
   243  		return
   244  	}
   245  
   246  	controlExecutionCtx := r.getControlQueryContext(ctx)
   247  
   248  	// execute the control query
   249  	// NOTE no need to pass an OnComplete callback - we are already closing our session after waiting for results
   250  	log.Printf("[TRACE] execute start for, %s\n", control.Name())
   251  	queryResult, err := client.ExecuteInSession(controlExecutionCtx, dbSession, nil, resolvedQuery.ExecuteSQL, resolvedQuery.Args...)
   252  	log.Printf("[TRACE] execute finish for, %s\n", control.Name())
   253  
   254  	if err != nil {
   255  		r.attempts++
   256  
   257  		// is this an rpc EOF error - meaning that the plugin somehow crashed
   258  		if grpc.IsGRPCConnectivityError(err) {
   259  			if r.attempts < constants.MaxControlRunAttempts {
   260  				log.Printf("[TRACE] control %s query failed with plugin connectivity error %s - retrying…", r.Control.Name(), err)
   261  				// recurse into this function to retry using the original context - which Execute will use to create it's own timeout context
   262  				r.execute(ctx, client)
   263  				return
   264  			} else {
   265  				log.Printf("[TRACE] control %s query failed again with plugin connectivity error %s - NOT retrying…", r.Control.Name(), err)
   266  			}
   267  		}
   268  		r.setError(ctx, err)
   269  		return
   270  	}
   271  
   272  	r.queryResult = queryResult
   273  
   274  	// now wait for control completion
   275  	log.Printf("[TRACE] wait result for, %s\n", control.Name())
   276  	r.waitForResults(ctx)
   277  	log.Printf("[TRACE] finish result for, %s\n", control.Name())
   278  }
   279  
   280  // try to acquire a database session - retry up to 4 times if there is an error
   281  func (r *ControlRun) acquireSession(ctx context.Context, client db_common.Client) *db_common.AcquireSessionResult {
   282  	var sessionResult *db_common.AcquireSessionResult
   283  	for attempt := 0; attempt < 4; attempt++ {
   284  		sessionResult = client.AcquireSession(ctx)
   285  		if sessionResult.Error == nil || error_helpers.IsCancelledError(sessionResult.Error) {
   286  			break
   287  		}
   288  
   289  		log.Printf("[TRACE] controlRun %s acquireSession failed with error: %s - retrying", r.ControlId, sessionResult.Error)
   290  	}
   291  
   292  	return sessionResult
   293  }
   294  
   295  // create a context with status updates disabled (we do not want to show 'loading' results)
   296  func (r *ControlRun) getControlQueryContext(ctx context.Context) context.Context {
   297  	// disable the status spinner to hide 'loading' results)
   298  	newCtx := statushooks.DisableStatusHooks(ctx)
   299  
   300  	return newCtx
   301  }
   302  
   303  func (r *ControlRun) resolveControlQuery(control *modconfig.Control) (*modconfig.ResolvedQuery, error) {
   304  	resolvedQuery, err := r.Tree.Workspace.ResolveQueryFromQueryProvider(control, nil)
   305  	if err != nil {
   306  		return nil, fmt.Errorf(`cannot run %s - failed to resolve query "%s": %s`, control.Name(), typehelpers.SafeString(control.SQL), err.Error())
   307  	}
   308  	return resolvedQuery, nil
   309  }
   310  
   311  func (r *ControlRun) waitForResults(ctx context.Context) {
   312  	defer func() {
   313  		dimensionsSchema := r.getDimensionSchema()
   314  		// convert the data to snapshot format
   315  		r.Data = r.Rows.ToLeafData(dimensionsSchema)
   316  	}()
   317  
   318  	for {
   319  		select {
   320  		case <-ctx.Done():
   321  			r.setError(ctx, ctx.Err())
   322  			return
   323  		case row := <-*r.queryResult.RowChan:
   324  			// nil row means control run is complete
   325  			if row == nil {
   326  				// nil row means we are done
   327  				r.setRunStatus(ctx, dashboardtypes.RunComplete)
   328  				r.createdOrderedResultRows()
   329  				return
   330  			}
   331  			// if the row is in error then we terminate the run
   332  			if row.Error != nil {
   333  				// set error status (parent summary will be set from parent defer)
   334  				r.setError(ctx, row.Error)
   335  				return
   336  			}
   337  
   338  			// so all is ok - create another result row
   339  			result, err := NewResultRow(r, row, r.queryResult.Cols)
   340  			if err != nil {
   341  				r.setError(ctx, err)
   342  				return
   343  			}
   344  			r.addResultRow(result)
   345  		case <-r.doneChan:
   346  			return
   347  		}
   348  	}
   349  }
   350  
   351  func (r *ControlRun) getDimensionSchema() map[string]*queryresult.ColumnDef {
   352  	var dimensionsSchema = make(map[string]*queryresult.ColumnDef)
   353  
   354  	for _, row := range r.Rows {
   355  		for _, dim := range row.Dimensions {
   356  			if _, ok := dimensionsSchema[dim.Key]; !ok {
   357  				// add to map
   358  				dimensionsSchema[dim.Key] = &queryresult.ColumnDef{
   359  					Name:     dim.Key,
   360  					DataType: dim.SqlType,
   361  				}
   362  				// also add to DimensionKeys
   363  				r.DimensionKeys = append(r.DimensionKeys, dim.Key)
   364  			}
   365  		}
   366  	}
   367  	// add keys to group
   368  	r.Group.addDimensionKeys(r.DimensionKeys...)
   369  	return dimensionsSchema
   370  }
   371  
   372  // add the result row to our results and update the summary with the row status
   373  func (r *ControlRun) addResultRow(row *ResultRow) {
   374  	// update results
   375  	r.rowMap[row.Status] = append(r.rowMap[row.Status], row)
   376  
   377  	// update summary
   378  	switch row.Status {
   379  	case constants.ControlOk:
   380  		r.Summary.Ok++
   381  	case constants.ControlAlarm:
   382  		r.Summary.Alarm++
   383  	case constants.ControlSkip:
   384  		r.Summary.Skip++
   385  	case constants.ControlInfo:
   386  		r.Summary.Info++
   387  	case constants.ControlError:
   388  		r.Summary.Error++
   389  	}
   390  }
   391  
   392  // populate ordered list of rows
   393  func (r *ControlRun) createdOrderedResultRows() {
   394  	statusOrder := []string{constants.ControlError, constants.ControlAlarm, constants.ControlInfo, constants.ControlOk, constants.ControlSkip}
   395  	for _, status := range statusOrder {
   396  		r.Rows = append(r.Rows, r.rowMap[status]...)
   397  	}
   398  }
   399  
   400  func (r *ControlRun) setRunStatus(ctx context.Context, status dashboardtypes.RunStatus) {
   401  	r.stateLock.Lock()
   402  	r.RunStatus = status
   403  	r.stateLock.Unlock()
   404  
   405  	if r.Finished() {
   406  		// close the doneChan - we don't need it anymore
   407  		close(r.doneChan)
   408  	}
   409  }