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 }