github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/cmd/check.go (about)

     1  package cmd
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"os"
     8  	"strings"
     9  
    10  	"github.com/spf13/cobra"
    11  	"github.com/spf13/viper"
    12  	"github.com/thediveo/enumflag/v2"
    13  	"github.com/turbot/go-kit/helpers"
    14  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    15  	"github.com/turbot/steampipe/pkg/cmdconfig"
    16  	"github.com/turbot/steampipe/pkg/constants"
    17  	"github.com/turbot/steampipe/pkg/contexthelpers"
    18  	"github.com/turbot/steampipe/pkg/control"
    19  	"github.com/turbot/steampipe/pkg/control/controldisplay"
    20  	"github.com/turbot/steampipe/pkg/control/controlexecute"
    21  	"github.com/turbot/steampipe/pkg/control/controlstatus"
    22  	"github.com/turbot/steampipe/pkg/display"
    23  	"github.com/turbot/steampipe/pkg/error_helpers"
    24  	"github.com/turbot/steampipe/pkg/statushooks"
    25  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    26  	"github.com/turbot/steampipe/pkg/utils"
    27  	"github.com/turbot/steampipe/pkg/workspace"
    28  )
    29  
    30  // variable used to assign the timing mode flag
    31  var checkTimingMode = constants.CheckTimingModeOff
    32  
    33  // variable used to assign the output mode flag
    34  var checkOutputMode = constants.CheckOutputModeText
    35  
    36  func checkCmd() *cobra.Command {
    37  	cmd := &cobra.Command{
    38  		Use:              "check [flags] [mod/benchmark/control/\"all\"]",
    39  		TraverseChildren: true,
    40  		Args:             cobra.ArbitraryArgs,
    41  		Run:              runCheckCmd,
    42  		Short:            "Execute one or more controls",
    43  		Long: `Execute one or more Steampipe benchmarks and controls.
    44  
    45  You may specify one or more benchmarks or controls to run (separated by a space), or run 'steampipe check all' to run all controls in the workspace.`,
    46  		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    47  			ctx := cmd.Context()
    48  			workspaceResources, err := workspace.LoadResourceNames(ctx, viper.GetString(constants.ArgModLocation))
    49  			if err != nil {
    50  				return []string{}, cobra.ShellCompDirectiveError
    51  			}
    52  
    53  			completions := []string{}
    54  
    55  			for _, item := range workspaceResources.GetSortedBenchmarksAndControlNames() {
    56  				if strings.HasPrefix(item, toComplete) {
    57  					completions = append(completions, item)
    58  				}
    59  			}
    60  
    61  			return completions, cobra.ShellCompDirectiveNoFileComp
    62  		},
    63  	}
    64  
    65  	cmdconfig.
    66  		OnCmd(cmd).
    67  		AddCloudFlags().
    68  		AddWorkspaceDatabaseFlag().
    69  		AddModLocationFlag().
    70  		AddBoolFlag(constants.ArgHeader, true, "Include column headers for csv and table output").
    71  		AddBoolFlag(constants.ArgHelp, false, "Help for check", cmdconfig.FlagOptions.WithShortHand("h")).
    72  		AddStringFlag(constants.ArgSeparator, ",", "Separator string for csv output").
    73  		AddVarFlag(enumflag.New(&checkOutputMode, constants.ArgOutput, constants.CheckOutputModeIds, enumflag.EnumCaseInsensitive),
    74  			constants.ArgOutput,
    75  			fmt.Sprintf("Output format; one of: %s", strings.Join(constants.FlagValues(constants.CheckOutputModeIds), ", "))).
    76  		AddVarFlag(enumflag.New(&checkTimingMode, constants.ArgTiming, constants.CheckTimingModeIds, enumflag.EnumCaseInsensitive),
    77  			constants.ArgTiming,
    78  			fmt.Sprintf("Display timing information; one of: %s", strings.Join(constants.FlagValues(constants.CheckTimingModeIds), ", ")),
    79  			cmdconfig.FlagOptions.NoOptDefVal(constants.CheckTimingModeIds[checkTimingMode][0])).
    80  		AddStringSliceFlag(constants.ArgSearchPath, nil, "Set a custom search_path for the steampipe user for a check session (comma-separated)").
    81  		AddStringSliceFlag(constants.ArgSearchPathPrefix, nil, "Set a prefix to the current search path for a check session (comma-separated)").
    82  		AddStringFlag(constants.ArgTheme, "dark", "Set the output theme for 'text' output: light, dark or plain").
    83  		AddStringSliceFlag(constants.ArgExport, nil, "Export output to file, supported formats: csv, html, json, md, nunit3, sps (snapshot), asff").
    84  		AddBoolFlag(constants.ArgProgress, true, "Display control execution progress").
    85  		AddBoolFlag(constants.ArgDryRun, false, "Show which controls will be run without running them").
    86  		AddStringSliceFlag(constants.ArgTag, nil, "Filter controls based on their tag values ('--tag key=value')").
    87  		AddStringSliceFlag(constants.ArgVarFile, nil, "Specify an .spvar file containing variable values").
    88  		// NOTE: use StringArrayFlag for ArgVariable, not StringSliceFlag
    89  		// Cobra will interpret values passed to a StringSliceFlag as CSV,
    90  		// where args passed to StringArrayFlag are not parsed and used raw
    91  		AddStringArrayFlag(constants.ArgVariable, nil, "Specify the value of a variable").
    92  		AddStringFlag(constants.ArgWhere, "", "SQL 'where' clause, or named query, used to filter controls (cannot be used with '--tag')").
    93  		AddIntFlag(constants.ArgDatabaseQueryTimeout, constants.DatabaseDefaultCheckQueryTimeout, "The query timeout").
    94  		AddIntFlag(constants.ArgMaxParallel, constants.DefaultMaxConnections, "The maximum number of concurrent database connections to open").
    95  		AddBoolFlag(constants.ArgModInstall, true, "Specify whether to install mod dependencies before running the check").
    96  		AddBoolFlag(constants.ArgInput, true, "Enable interactive prompts").
    97  		AddBoolFlag(constants.ArgSnapshot, false, "Create snapshot in Turbot Pipes with the default (workspace) visibility").
    98  		AddBoolFlag(constants.ArgShare, false, "Create snapshot in Turbot Pipes with 'anyone_with_link' visibility").
    99  		AddStringArrayFlag(constants.ArgSnapshotTag, nil, "Specify tags to set on the snapshot").
   100  		AddStringFlag(constants.ArgSnapshotLocation, "", "The location to write snapshots - either a local file path or a Turbot Pipes workspace").
   101  		AddStringFlag(constants.ArgSnapshotTitle, "", "The title to give a snapshot")
   102  
   103  	cmd.AddCommand(getListSubCmd(listSubCmdOptions{parentCmd: cmd}))
   104  	return cmd
   105  }
   106  
   107  // exitCode=0 no runtime errors, no control alarms or errors
   108  // exitCode=1 no runtime errors, 1 or more control alarms, no control errors
   109  // exitCode=2 no runtime errors, 1 or more control errors
   110  // exitCode=3+ runtime errors
   111  
   112  func runCheckCmd(cmd *cobra.Command, args []string) {
   113  	utils.LogTime("runCheckCmd start")
   114  
   115  	// setup a cancel context and start cancel handler
   116  	ctx, cancel := context.WithCancel(cmd.Context())
   117  	contexthelpers.StartCancelHandler(cancel)
   118  
   119  	defer func() {
   120  		utils.LogTime("runCheckCmd end")
   121  		if r := recover(); r != nil {
   122  			error_helpers.ShowError(ctx, helpers.ToError(r))
   123  			exitCode = constants.ExitCodeUnknownErrorPanic
   124  		}
   125  	}()
   126  
   127  	// verify we have an argument
   128  	if !validateCheckArgs(ctx, cmd, args) {
   129  		exitCode = constants.ExitCodeInsufficientOrWrongInputs
   130  		return
   131  	}
   132  	// if diagnostic mode is set, print out config and return
   133  	if _, ok := os.LookupEnv(constants.EnvConfigDump); ok {
   134  		cmdconfig.DisplayConfig()
   135  		return
   136  	}
   137  
   138  	// verify that no other benchmarks/controls are given with an all
   139  	if helpers.StringSliceContains(args, "all") && len(args) > 1 {
   140  		error_helpers.FailOnError(sperr.New("cannot execute 'all' with other benchmarks/controls"))
   141  	}
   142  
   143  	// show the status spinner
   144  	statushooks.Show(ctx)
   145  
   146  	// initialise
   147  	statushooks.SetStatus(ctx, "Initializing...")
   148  	// disable status hooks in init - otherwise we will end up
   149  	// getting status updates all the way down from the service layer
   150  	initData := control.NewInitData(ctx)
   151  	if initData.Result.Error != nil {
   152  		exitCode = constants.ExitCodeInitializationFailed
   153  		error_helpers.ShowError(ctx, initData.Result.Error)
   154  		return
   155  	}
   156  	defer initData.Cleanup(ctx)
   157  
   158  	// hide the spinner so that warning messages can be shown
   159  	statushooks.Done(ctx)
   160  
   161  	// if there is a usage warning we display it
   162  	initData.Result.DisplayMessages()
   163  
   164  	// pull out useful properties
   165  	totalAlarms, totalErrors := 0, 0
   166  
   167  	// get the execution trees
   168  	// depending on the set of arguments and the export targets, we may get more than one
   169  	// example :
   170  	// "check benchmark.b1 benchmark.b2 --export check.json" would give one merged tree
   171  	// "check benchmark.b1 benchmark.b2 --export json" would give multiple trees
   172  	trees, err := getExecutionTrees(ctx, initData, args...)
   173  	error_helpers.FailOnError(err)
   174  
   175  	// execute controls synchronously (execute returns the number of alarms and errors)
   176  	for _, namedTree := range trees {
   177  		err = executeTree(ctx, namedTree.tree, initData)
   178  		if err != nil {
   179  			error_helpers.ShowError(ctx, err)
   180  			continue
   181  		}
   182  
   183  		// append the total number of alarms and errors for multiple runs
   184  		totalAlarms += namedTree.tree.Root.Summary.Status.Alarm
   185  		totalErrors += namedTree.tree.Root.Summary.Status.Error
   186  
   187  		err = publishSnapshot(ctx, namedTree.tree, viper.GetBool(constants.ArgShare), viper.GetBool(constants.ArgSnapshot))
   188  		if err != nil {
   189  			error_helpers.ShowError(ctx, err)
   190  			continue
   191  		}
   192  
   193  		printTiming(namedTree.tree)
   194  
   195  		err = exportExecutionTree(ctx, namedTree, initData, viper.GetStringSlice(constants.ArgExport))
   196  		if err != nil {
   197  			error_helpers.ShowError(ctx, err)
   198  			continue
   199  		}
   200  	}
   201  
   202  	// set the defined exit code after successful execution
   203  	exitCode = getExitCode(totalAlarms, totalErrors)
   204  }
   205  
   206  // exportExecutionTree relies on the fact that the given tree is already executed
   207  func exportExecutionTree(ctx context.Context, namedTree *namedExecutionTree, initData *control.InitData, exportArgs []string) error {
   208  	statushooks.Show(ctx)
   209  	defer statushooks.Done(ctx)
   210  
   211  	if error_helpers.IsContextCanceled(ctx) {
   212  		return ctx.Err()
   213  	}
   214  
   215  	exportMsg, err := initData.ExportManager.DoExport(ctx, namedTree.name, namedTree.tree, exportArgs)
   216  	if err != nil {
   217  		return err
   218  	}
   219  
   220  	// print the location where the file is exported if progress=true
   221  	if len(exportMsg) > 0 && viper.GetBool(constants.ArgProgress) {
   222  		fmt.Printf("\n")
   223  		fmt.Println(strings.Join(exportMsg, "\n"))
   224  		fmt.Printf("\n")
   225  	}
   226  
   227  	return nil
   228  }
   229  
   230  // executeTree executes and displays the (table) results of an execution
   231  func executeTree(ctx context.Context, tree *controlexecute.ExecutionTree, initData *control.InitData) error {
   232  	// create a context with check status hooks
   233  	checkCtx := createCheckContext(ctx)
   234  	err := tree.Execute(checkCtx)
   235  	if err != nil {
   236  		return err
   237  	}
   238  
   239  	err = displayControlResults(checkCtx, tree, initData.OutputFormatter)
   240  	if err != nil {
   241  		return err
   242  	}
   243  	return nil
   244  }
   245  
   246  func publishSnapshot(ctx context.Context, executionTree *controlexecute.ExecutionTree, shouldShare bool, shouldUpload bool) error {
   247  	if error_helpers.IsContextCanceled(ctx) {
   248  		return ctx.Err()
   249  	}
   250  	// if the share args are set, create a snapshot and share it
   251  	if shouldShare || shouldUpload {
   252  		statushooks.SetStatus(ctx, "Publishing snapshot")
   253  		return controldisplay.PublishSnapshot(ctx, executionTree, shouldShare)
   254  	}
   255  	return nil
   256  }
   257  
   258  // getExecutionTrees returns a list of execution trees with the names of their export targets
   259  // if the --export flag has the name of a file, a single merged tree is generated from the positional arguments
   260  // otherwise, one tree is generated for each argument
   261  //
   262  // this is necessary, since exporters can only export entire execution trees and when a file name is provided, we want to export the whole tree into one file
   263  //
   264  // example :
   265  // "check benchmark.b1 benchmark.b2 --export check.json" would give one merged tree
   266  // "check benchmark.b1 benchmark.b2 --export json" would give multiple trees
   267  func getExecutionTrees(ctx context.Context, initData *control.InitData, args ...string) ([]*namedExecutionTree, error) {
   268  	var trees []*namedExecutionTree
   269  
   270  	if initData.ExportManager.HasNamedExport(viper.GetStringSlice(constants.ArgExport)) {
   271  		// create a single merged execution tree from all arguments
   272  		executionTree, err := controlexecute.NewExecutionTree(ctx, initData.Workspace, initData.Client, initData.ControlFilterWhereClause, args...)
   273  		if err != nil {
   274  			return nil, sperr.WrapWithMessage(err, "could not create merged execution tree")
   275  		}
   276  		name := fmt.Sprintf("check.%s", initData.Workspace.Mod.ShortName)
   277  		trees = append(trees, newNamedExecutionTree(name, executionTree))
   278  	} else {
   279  		for _, arg := range args {
   280  			if error_helpers.IsContextCanceled(ctx) {
   281  				return nil, ctx.Err()
   282  			}
   283  			executionTree, err := controlexecute.NewExecutionTree(ctx, initData.Workspace, initData.Client, initData.ControlFilterWhereClause, arg)
   284  			if err != nil {
   285  				return nil, sperr.WrapWithMessage(err, "could not create execution tree for %s", arg)
   286  			}
   287  			name, err := getExportName(arg, initData.Workspace.Mod.ShortName)
   288  			if err != nil {
   289  				return nil, sperr.WrapWithMessage(err, "could not evaluate export name for %s", arg)
   290  			}
   291  			trees = append(trees, newNamedExecutionTree(name, executionTree))
   292  		}
   293  	}
   294  	return trees, ctx.Err()
   295  }
   296  
   297  // getExportName resolves the base name of the target file
   298  func getExportName(targetName string, modShortName string) (string, error) {
   299  	parsedName, _ := modconfig.ParseResourceName(targetName)
   300  	if targetName == "all" {
   301  		// there will be no block type = manually construct name
   302  		return fmt.Sprintf("%s.%s", modShortName, parsedName.Name), nil
   303  	}
   304  	// default to just converting to valid resource name
   305  	return parsedName.ToFullNameWithMod(modShortName)
   306  }
   307  
   308  // get the exit code for successful check run
   309  func getExitCode(alarms int, errors int) int {
   310  	// 1 or more control errors, return exitCode=2
   311  	if errors > 0 {
   312  		return constants.ExitCodeControlsError
   313  	}
   314  	// 1 or more controls in alarm, return exitCode=1
   315  	if alarms > 0 {
   316  		return constants.ExitCodeControlsAlarm
   317  	}
   318  	// no controls in alarm/error
   319  	return constants.ExitCodeSuccessful
   320  }
   321  
   322  // create the context for the check run - add a control status renderer
   323  func createCheckContext(ctx context.Context) context.Context {
   324  	return controlstatus.AddControlHooksToContext(ctx, controlstatus.NewStatusControlHooks())
   325  }
   326  
   327  func validateCheckArgs(ctx context.Context, cmd *cobra.Command, args []string) bool {
   328  	if len(args) == 0 {
   329  		fmt.Println()
   330  		error_helpers.ShowError(ctx, fmt.Errorf("you must provide at least one argument"))
   331  		fmt.Println()
   332  		//nolint:errcheck // cmd.Help always returns a nil error
   333  		cmd.Help()
   334  		fmt.Println()
   335  		return false
   336  	}
   337  
   338  	if err := cmdconfig.ValidateSnapshotArgs(ctx); err != nil {
   339  		error_helpers.ShowError(ctx, err)
   340  		return false
   341  	}
   342  
   343  	// only 1 character is allowed for '--separator'
   344  	if len(viper.GetString(constants.ArgSeparator)) > 1 {
   345  		error_helpers.ShowError(ctx, fmt.Errorf("'--%s' can be 1 character long at most", constants.ArgSeparator))
   346  		return false
   347  	}
   348  
   349  	// only 1 of 'share' and 'snapshot' may be set
   350  	if viper.GetBool(constants.ArgShare) && viper.GetBool(constants.ArgSnapshot) {
   351  		error_helpers.ShowError(ctx, fmt.Errorf("only 1 of '--%s' and '--%s' may be set", constants.ArgShare, constants.ArgSnapshot))
   352  		return false
   353  	}
   354  
   355  	// if both '--where' and '--tag' have been used, then it's an error
   356  	if viper.IsSet(constants.ArgWhere) && viper.IsSet(constants.ArgTag) {
   357  		error_helpers.ShowError(ctx, fmt.Errorf("only 1 of '--%s' and '--%s' may be set", constants.ArgWhere, constants.ArgTag))
   358  		return false
   359  	}
   360  
   361  	return true
   362  }
   363  
   364  func printTiming(tree *controlexecute.ExecutionTree) {
   365  	if !shouldPrintTiming() {
   366  		return
   367  	}
   368  	headers := []string{"", "Duration"}
   369  	var rows [][]string
   370  
   371  	for _, rg := range tree.Root.Groups {
   372  		if rg.GroupItem.GetUnqualifiedName() == "benchmark.root" {
   373  			// this is the created root benchmark
   374  			// adds the children
   375  			for _, g := range rg.Groups {
   376  				rows = append(rows, []string{g.GroupItem.GetUnqualifiedName(), rg.Duration.String()})
   377  			}
   378  			continue
   379  		}
   380  		rows = append(rows, []string{rg.GroupItem.GetUnqualifiedName(), rg.Duration.String()})
   381  	}
   382  	for _, c := range tree.Root.ControlRuns {
   383  		rows = append(rows, []string{c.Control.GetUnqualifiedName(), c.Duration.String()})
   384  	}
   385  	// blank line after renderer output
   386  	fmt.Println()
   387  	fmt.Println("Timing:")
   388  	display.ShowWrappedTable(headers, rows, &display.ShowWrappedTableOptions{AutoMerge: false})
   389  }
   390  
   391  func shouldPrintTiming() bool {
   392  	outputFormat := viper.GetString(constants.ArgOutput)
   393  	timingMode := viper.GetString(constants.ArgTiming)
   394  	return (timingMode != constants.ArgOff && !viper.GetBool(constants.ArgDryRun)) &&
   395  		(outputFormat == constants.OutputFormatText || outputFormat == constants.OutputFormatBrief)
   396  }
   397  
   398  func displayControlResults(ctx context.Context, executionTree *controlexecute.ExecutionTree, formatter controldisplay.Formatter) error {
   399  	reader, err := formatter.Format(ctx, executionTree)
   400  	if err != nil {
   401  		return err
   402  	}
   403  	_, err = io.Copy(os.Stdout, reader)
   404  	return err
   405  }
   406  
   407  type namedExecutionTree struct {
   408  	tree *controlexecute.ExecutionTree
   409  	name string
   410  }
   411  
   412  func newNamedExecutionTree(name string, tree *controlexecute.ExecutionTree) *namedExecutionTree {
   413  	return &namedExecutionTree{
   414  		tree: tree,
   415  		name: name,
   416  	}
   417  }