mirror of
https://github.com/turbot/steampipe.git
synced 2026-02-18 22:00:12 -05:00
448 lines
14 KiB
Go
448 lines
14 KiB
Go
package cmd
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"github.com/turbot/steampipe/pkg/initialisation"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
"github.com/turbot/go-kit/helpers"
|
|
"github.com/turbot/steampipe/pkg/cmdconfig"
|
|
"github.com/turbot/steampipe/pkg/constants"
|
|
"github.com/turbot/steampipe/pkg/contexthelpers"
|
|
"github.com/turbot/steampipe/pkg/control"
|
|
"github.com/turbot/steampipe/pkg/control/controldisplay"
|
|
"github.com/turbot/steampipe/pkg/control/controlexecute"
|
|
"github.com/turbot/steampipe/pkg/control/controlstatus"
|
|
"github.com/turbot/steampipe/pkg/display"
|
|
"github.com/turbot/steampipe/pkg/interactive"
|
|
"github.com/turbot/steampipe/pkg/statushooks"
|
|
"github.com/turbot/steampipe/pkg/utils"
|
|
"github.com/turbot/steampipe/pkg/workspace"
|
|
)
|
|
|
|
func checkCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "check [flags] [mod/benchmark/control/\"all\"]",
|
|
TraverseChildren: true,
|
|
Args: cobra.ArbitraryArgs,
|
|
Run: runCheckCmd,
|
|
Short: "Execute one or more controls",
|
|
Long: `Execute one or more Steampipe benchmarks and controls.
|
|
|
|
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.`,
|
|
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
workspaceResources, err := workspace.LoadResourceNames(viper.GetString(constants.ArgWorkspaceChDir))
|
|
if err != nil {
|
|
return []string{}, cobra.ShellCompDirectiveError
|
|
}
|
|
|
|
completions := []string{}
|
|
|
|
for _, item := range workspaceResources.GetSortedBenchmarksAndControlNames() {
|
|
if strings.HasPrefix(item, toComplete) {
|
|
completions = append(completions, item)
|
|
}
|
|
}
|
|
|
|
return completions, cobra.ShellCompDirectiveNoFileComp
|
|
},
|
|
}
|
|
|
|
cmdconfig.
|
|
OnCmd(cmd).
|
|
AddBoolFlag(constants.ArgHeader, "", true, "Include column headers for csv and table output").
|
|
AddBoolFlag(constants.ArgHelp, "h", false, "Help for check").
|
|
AddStringFlag(constants.ArgSeparator, "", ",", "Separator string for csv output").
|
|
AddStringFlag(constants.ArgOutput, "", constants.OutputFormatText, "Select a console output format: brief, csv, html, json, md, text or none").
|
|
AddBoolFlag(constants.ArgTiming, "", false, "Turn on the timer which reports check time").
|
|
AddStringSliceFlag(constants.ArgSearchPath, "", nil, "Set a custom search_path for the steampipe user for a check session (comma-separated)").
|
|
AddStringSliceFlag(constants.ArgSearchPathPrefix, "", nil, "Set a prefix to the current search path for a check session (comma-separated)").
|
|
AddStringFlag(constants.ArgTheme, "", "dark", "Set the output theme for 'text' output: light, dark or plain").
|
|
AddStringSliceFlag(constants.ArgExport, "", nil, "Export output to files in various output formats: csv, html, json, md, nunit3 or asff (json)").
|
|
AddBoolFlag(constants.ArgProgress, "", true, "Display control execution progress").
|
|
AddBoolFlag(constants.ArgDryRun, "", false, "Show which controls will be run without running them").
|
|
AddStringSliceFlag(constants.ArgTag, "", nil, "Filter controls based on their tag values ('--tag key=value')").
|
|
AddStringSliceFlag(constants.ArgVarFile, "", nil, "Specify an .spvar file containing variable values").
|
|
// NOTE: use StringArrayFlag for ArgVariable, not StringSliceFlag
|
|
// Cobra will interpret values passed to a StringSliceFlag as CSV,
|
|
// where args passed to StringArrayFlag are not parsed and used raw
|
|
AddStringArrayFlag(constants.ArgVariable, "", nil, "Specify the value of a variable").
|
|
AddStringFlag(constants.ArgWhere, "", "", "SQL 'where' clause, or named query, used to filter controls (cannot be used with '--tag')").
|
|
AddIntFlag(constants.ArgMaxParallel, "", constants.DefaultMaxConnections, "The maximum number of parallel executions", cmdconfig.FlagOptions.Hidden()).
|
|
AddBoolFlag(constants.ArgModInstall, "", true, "Specify whether to install mod dependencies before running the check").
|
|
AddBoolFlag(constants.ArgInput, "", true, "Enable interactive prompts").
|
|
AddStringFlag(constants.ArgSnapshot, "", "", "Create snapshot in Steampipe Cloud with the default (workspace) visibility.", cmdconfig.FlagOptions.NoOptDefVal(constants.ArgShareNoOptDefault)).
|
|
AddStringFlag(constants.ArgShare, "", "", "Create snapshot in Steampipe Cloud with 'anyone_with_link' visibility.", cmdconfig.FlagOptions.NoOptDefVal(constants.ArgShareNoOptDefault))
|
|
|
|
return cmd
|
|
}
|
|
|
|
// exitCode=1 For unknown errors resulting in panics
|
|
// exitCode=2 For insufficient args
|
|
|
|
func runCheckCmd(cmd *cobra.Command, args []string) {
|
|
utils.LogTime("runCheckCmd start")
|
|
initData := &initialisation.InitData{}
|
|
|
|
// setup a cancel context and start cancel handler
|
|
ctx, cancel := context.WithCancel(cmd.Context())
|
|
contexthelpers.StartCancelHandler(cancel)
|
|
// create a context with check status hooks
|
|
ctx = createCheckContext(ctx)
|
|
|
|
defer func() {
|
|
utils.LogTime("runCheckCmd end")
|
|
if r := recover(); r != nil {
|
|
utils.ShowError(ctx, helpers.ToError(r))
|
|
exitCode = constants.ExitCodeUnknownErrorPanic
|
|
}
|
|
|
|
initData.Cleanup(ctx)
|
|
}()
|
|
|
|
// verify we have an argument
|
|
if !validateCheckArgs(ctx, cmd, args) {
|
|
return
|
|
}
|
|
|
|
// if progress is disabled, update context to contain a null status hooks object
|
|
if !viper.GetBool(constants.ArgProgress) {
|
|
statushooks.DisableStatusHooks(ctx)
|
|
}
|
|
|
|
// initialise
|
|
initData = initialiseCheck(ctx)
|
|
utils.FailOnError(initData.Result.Error)
|
|
// if there is a usage warning we display it
|
|
initData.Result.DisplayMessages()
|
|
|
|
// pull out useful properties
|
|
workspace := initData.Workspace
|
|
client := initData.Client
|
|
failures := 0
|
|
var exportErrors []error
|
|
exportErrorsLock := sync.Mutex{}
|
|
exportWaitGroup := sync.WaitGroup{}
|
|
var durations []time.Duration
|
|
|
|
// treat each arg as a separate execution
|
|
for _, arg := range args {
|
|
if utils.IsContextCancelled(ctx) {
|
|
durations = append(durations, 0)
|
|
// skip over this arg, since the execution was cancelled
|
|
// (do not just quit as we want to populate the durations)
|
|
continue
|
|
}
|
|
|
|
// get the export formats for this argument
|
|
exportTargets, err := getExportTargets(arg)
|
|
utils.FailOnError(err)
|
|
|
|
// create the execution tree
|
|
executionTree, err := controlexecute.NewExecutionTree(ctx, workspace, client, arg)
|
|
utils.FailOnError(err)
|
|
|
|
// execute controls synchronously (execute returns the number of failures)
|
|
failures += executionTree.Execute(ctx)
|
|
err = displayControlResults(ctx, executionTree)
|
|
utils.FailOnError(err)
|
|
|
|
if len(exportTargets) > 0 {
|
|
d := control.ExportData{
|
|
ExecutionTree: executionTree,
|
|
Targets: exportTargets,
|
|
ErrorsLock: &exportErrorsLock,
|
|
Errors: exportErrors,
|
|
WaitGroup: &exportWaitGroup,
|
|
}
|
|
exportCheckResult(ctx, &d)
|
|
}
|
|
|
|
durations = append(durations, executionTree.EndTime.Sub(executionTree.StartTime))
|
|
}
|
|
|
|
// wait for exports to complete
|
|
exportWaitGroup.Wait()
|
|
|
|
if len(exportErrors) > 0 {
|
|
utils.ShowError(ctx, utils.CombineErrors(exportErrors...))
|
|
}
|
|
|
|
if shouldPrintTiming() {
|
|
printTiming(args, durations)
|
|
}
|
|
|
|
// set global exit code
|
|
exitCode = failures
|
|
}
|
|
|
|
// create the context for the check run - add a control status renderer
|
|
func createCheckContext(ctx context.Context) context.Context {
|
|
return controlstatus.AddControlHooksToContext(ctx, controlstatus.NewStatusControlHooks())
|
|
}
|
|
|
|
func validateCheckArgs(ctx context.Context, cmd *cobra.Command, args []string) bool {
|
|
if len(args) == 0 {
|
|
fmt.Println()
|
|
utils.ShowError(ctx, fmt.Errorf("you must provide at least one argument"))
|
|
fmt.Println()
|
|
cmd.Help()
|
|
fmt.Println()
|
|
exitCode = constants.ExitCodeInsufficientOrWrongArguments
|
|
return false
|
|
}
|
|
// only 1 of 'share' and 'snapshot' may be set
|
|
if len(viper.GetString(constants.ArgShare)) > 0 && len(viper.GetString(constants.ArgShare)) > 0 {
|
|
utils.ShowError(ctx, fmt.Errorf("only 1 of 'share' and 'dashboard' may be set"))
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func initialiseCheck(ctx context.Context) *initialisation.InitData {
|
|
statushooks.SetStatus(ctx, "Initializing...")
|
|
defer statushooks.Done(ctx)
|
|
|
|
// load the workspace
|
|
w, err := interactive.LoadWorkspacePromptingForVariables(ctx)
|
|
utils.FailOnErrorWithMessage(err, "failed to load workspace")
|
|
|
|
initData := initialisation.NewInitData(ctx, w)
|
|
if initData.Result.Error != nil {
|
|
return initData
|
|
}
|
|
|
|
// control specific init
|
|
if !w.ModfileExists() {
|
|
initData.Result.Error = workspace.ErrorNoModDefinition
|
|
}
|
|
|
|
if viper.GetString(constants.ArgOutput) == constants.OutputFormatNone {
|
|
// set progress to false
|
|
viper.Set(constants.ArgProgress, false)
|
|
}
|
|
// set color schema
|
|
err = initialiseCheckColorScheme()
|
|
if err != nil {
|
|
initData.Result.Error = err
|
|
return initData
|
|
}
|
|
|
|
if len(initData.Workspace.GetResourceMaps().Controls) == 0 {
|
|
initData.Result.AddWarnings("no controls found in current workspace")
|
|
}
|
|
|
|
if err := controldisplay.EnsureTemplates(); err != nil {
|
|
initData.Result.Error = err
|
|
return initData
|
|
}
|
|
|
|
return initData
|
|
}
|
|
|
|
func initialiseCheckColorScheme() error {
|
|
theme := viper.GetString(constants.ArgTheme)
|
|
if !viper.GetBool(constants.ConfigKeyIsTerminalTTY) {
|
|
// enforce plain output for non-terminals
|
|
theme = "plain"
|
|
}
|
|
themeDef, ok := controldisplay.ColorSchemes[theme]
|
|
if !ok {
|
|
return fmt.Errorf("invalid theme '%s'", theme)
|
|
}
|
|
scheme, err := controldisplay.NewControlColorScheme(themeDef)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
controldisplay.ControlColors = scheme
|
|
return nil
|
|
}
|
|
|
|
func printTiming(args []string, durations []time.Duration) {
|
|
headers := []string{"", "Duration"}
|
|
var rows [][]string
|
|
for idx, arg := range args {
|
|
rows = append(rows, []string{arg, durations[idx].String()})
|
|
}
|
|
// blank line after renderer output
|
|
fmt.Println()
|
|
fmt.Println("Timing:")
|
|
display.ShowWrappedTable(headers, rows, false)
|
|
}
|
|
|
|
func shouldPrintTiming() bool {
|
|
outputFormat := viper.GetString(constants.ArgOutput)
|
|
|
|
return (viper.GetBool(constants.ArgTiming) && !viper.GetBool(constants.ArgDryRun)) &&
|
|
(outputFormat == constants.OutputFormatText || outputFormat == constants.OutputFormatBrief)
|
|
}
|
|
|
|
func exportCheckResult(ctx context.Context, d *control.ExportData) {
|
|
d.WaitGroup.Add(1)
|
|
go func() {
|
|
err := exportControlResults(ctx, d.ExecutionTree, d.Targets)
|
|
if len(err) > 0 {
|
|
d.AddErrors(err)
|
|
}
|
|
d.WaitGroup.Done()
|
|
}()
|
|
}
|
|
|
|
func displayControlResults(ctx context.Context, executionTree *controlexecute.ExecutionTree) error {
|
|
output := viper.GetString(constants.ArgOutput)
|
|
formatter, _, err := parseOutputArg(output)
|
|
if err != nil {
|
|
fmt.Println(err)
|
|
return err
|
|
}
|
|
reader, err := formatter.Format(ctx, executionTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// tactical solution to prettify the json output
|
|
if output == "json" {
|
|
reader, err = prettifyJsonFromReader(reader)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = io.Copy(os.Stdout, reader)
|
|
return err
|
|
}
|
|
|
|
func exportControlResults(ctx context.Context, executionTree *controlexecute.ExecutionTree, targets []controldisplay.CheckExportTarget) []error {
|
|
errors := []error{}
|
|
for _, target := range targets {
|
|
if utils.IsContextCancelled(ctx) {
|
|
// set the error
|
|
errors = append(errors, ctx.Err())
|
|
// and skip forward
|
|
continue
|
|
}
|
|
|
|
dataToExport, err := target.Formatter.Format(ctx, executionTree)
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
continue
|
|
}
|
|
if utils.IsContextCancelled(ctx) {
|
|
errors = append(errors, ctx.Err())
|
|
continue
|
|
}
|
|
// create the output file
|
|
destination, err := os.Create(target.File)
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
continue
|
|
}
|
|
// tactical solution to prettify the json output
|
|
if target.Formatter.GetFormatName() == constants.OutputFormatJSON {
|
|
dataToExport, err = prettifyJsonFromReader(dataToExport)
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
_, err = io.Copy(destination, dataToExport)
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
continue
|
|
}
|
|
destination.Close()
|
|
}
|
|
|
|
return errors
|
|
}
|
|
|
|
func prettifyJsonFromReader(dataToExport io.Reader) (io.Reader, error) {
|
|
b, err := io.ReadAll(dataToExport)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var prettyJSON bytes.Buffer
|
|
|
|
err = json.Indent(&prettyJSON, b, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dataToExport = &prettyJSON
|
|
return dataToExport, nil
|
|
}
|
|
|
|
func getExportTargets(executing string) ([]controldisplay.CheckExportTarget, error) {
|
|
targets := []controldisplay.CheckExportTarget{}
|
|
targetErrors := []error{}
|
|
|
|
exports := viper.GetStringSlice(constants.ArgExport)
|
|
for _, export := range exports {
|
|
export = strings.TrimSpace(export)
|
|
|
|
if len(export) == 0 {
|
|
// if this is an empty string, ignore
|
|
continue
|
|
}
|
|
|
|
var fileName string
|
|
var formatter controldisplay.Formatter
|
|
|
|
formatter, fileName, err := parseExportArg(export)
|
|
if err != nil {
|
|
targetErrors = append(targetErrors, err)
|
|
continue
|
|
}
|
|
if formatter == nil {
|
|
targetErrors = append(targetErrors, controldisplay.ErrFormatterNotFound)
|
|
continue
|
|
}
|
|
|
|
if len(fileName) == 0 {
|
|
fileName = generateDefaultExportFileName(formatter, executing)
|
|
}
|
|
|
|
newTarget := controldisplay.NewCheckExportTarget(formatter, fileName)
|
|
isAlreadyAdded := false
|
|
for _, t := range targets {
|
|
if t.File == newTarget.File {
|
|
isAlreadyAdded = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !isAlreadyAdded {
|
|
targets = append(targets, newTarget)
|
|
}
|
|
}
|
|
|
|
return targets, utils.CombineErrors(targetErrors...)
|
|
}
|
|
|
|
// parseExportArg parses the flag value and returns a Formatter based on the value
|
|
func parseExportArg(arg string) (formatter controldisplay.Formatter, targetFileName string, err error) {
|
|
return controldisplay.GetTemplateExportFormatter(arg, true)
|
|
}
|
|
|
|
// parseOutputArg parses the --output flag value and returns the Formatter that can format the data
|
|
func parseOutputArg(arg string) (formatter controldisplay.Formatter, targetFileName string, err error) {
|
|
var found bool
|
|
if formatter, found = controldisplay.GetDefinedOutputFormatter(arg); found {
|
|
return
|
|
}
|
|
return controldisplay.GetTemplateExportFormatter(arg, false)
|
|
}
|
|
|
|
func generateDefaultExportFileName(formatter controldisplay.Formatter, executing string) string {
|
|
now := time.Now()
|
|
timeFormatted := fmt.Sprintf("%d%02d%02d-%02d%02d%02d", now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
|
|
return fmt.Sprintf("%s-%s%s", executing, timeFormatted, formatter.FileExtension())
|
|
}
|