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

     1  package cmd
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"encoding/json"
     7  	"fmt"
     8  	"os"
     9  	"path"
    10  	"strings"
    11  
    12  	"github.com/hashicorp/hcl/v2"
    13  	"github.com/spf13/cobra"
    14  	"github.com/spf13/viper"
    15  	"github.com/thediveo/enumflag/v2"
    16  	"github.com/turbot/go-kit/helpers"
    17  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    18  	"github.com/turbot/steampipe/pkg/cmdconfig"
    19  	"github.com/turbot/steampipe/pkg/connection_sync"
    20  	"github.com/turbot/steampipe/pkg/constants"
    21  	"github.com/turbot/steampipe/pkg/contexthelpers"
    22  	"github.com/turbot/steampipe/pkg/dashboard/dashboardexecute"
    23  	"github.com/turbot/steampipe/pkg/dashboard/dashboardtypes"
    24  	"github.com/turbot/steampipe/pkg/display"
    25  	"github.com/turbot/steampipe/pkg/error_helpers"
    26  	"github.com/turbot/steampipe/pkg/query"
    27  	"github.com/turbot/steampipe/pkg/query/queryexecute"
    28  	"github.com/turbot/steampipe/pkg/query/queryresult"
    29  	"github.com/turbot/steampipe/pkg/statushooks"
    30  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    31  	"github.com/turbot/steampipe/pkg/utils"
    32  	"github.com/turbot/steampipe/pkg/workspace"
    33  )
    34  
    35  // variable used to assign the timing mode flag
    36  var queryTimingMode = constants.QueryTimingModeOff
    37  
    38  // variable used to assign the output mode flag
    39  var queryOutputMode = constants.QueryOutputModeTable
    40  
    41  func queryCmd() *cobra.Command {
    42  	cmd := &cobra.Command{
    43  		Use:              "query",
    44  		TraverseChildren: true,
    45  		Args:             cobra.ArbitraryArgs,
    46  		Run:              runQueryCmd,
    47  		Short:            "Execute SQL queries interactively or by argument",
    48  		Long: `Execute SQL queries interactively, or by a query argument.
    49  
    50  Open a interactive SQL query console to Steampipe to explore your data and run
    51  multiple queries. If QUERY is passed on the command line then it will be run
    52  immediately and the command will exit.
    53  
    54  Examples:
    55  
    56    # Open an interactive query console
    57    steampipe query
    58  
    59    # Run a specific query directly
    60    steampipe query "select * from cloud"`,
    61  
    62  		ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
    63  			ctx := cmd.Context()
    64  			w, err := workspace.LoadResourceNames(ctx, viper.GetString(constants.ArgModLocation))
    65  			if err != nil {
    66  				return []string{}, cobra.ShellCompDirectiveError
    67  			}
    68  			namedQueries := []string{}
    69  			for _, name := range w.GetSortedNamedQueryNames() {
    70  				if strings.HasPrefix(name, toComplete) {
    71  					namedQueries = append(namedQueries, name)
    72  				}
    73  			}
    74  			return namedQueries, cobra.ShellCompDirectiveNoFileComp
    75  		},
    76  	}
    77  
    78  	// Notes:
    79  	// * In the future we may add --csv and --json flags as shortcuts for --output
    80  	cmdconfig.
    81  		OnCmd(cmd).
    82  		AddCloudFlags().
    83  		AddWorkspaceDatabaseFlag().
    84  		AddModLocationFlag().
    85  		AddBoolFlag(constants.ArgHelp, false, "Help for query", cmdconfig.FlagOptions.WithShortHand("h")).
    86  		AddBoolFlag(constants.ArgHeader, true, "Include column headers csv and table output").
    87  		AddStringFlag(constants.ArgSeparator, ",", "Separator string for csv output").
    88  		AddVarFlag(enumflag.New(&queryOutputMode, constants.ArgOutput, constants.QueryOutputModeIds, enumflag.EnumCaseInsensitive),
    89  			constants.ArgOutput,
    90  			fmt.Sprintf("Output format; one of: %s", strings.Join(constants.FlagValues(constants.QueryOutputModeIds), ", "))).
    91  		AddVarFlag(enumflag.New(&queryTimingMode, constants.ArgTiming, constants.QueryTimingModeIds, enumflag.EnumCaseInsensitive),
    92  			constants.ArgTiming,
    93  			fmt.Sprintf("Display query timing; one of: %s", strings.Join(constants.FlagValues(constants.QueryTimingModeIds), ", ")),
    94  			cmdconfig.FlagOptions.NoOptDefVal(constants.ArgOn)).
    95  		AddBoolFlag(constants.ArgWatch, true, "Watch SQL files in the current workspace (works only in interactive mode)").
    96  		AddStringSliceFlag(constants.ArgSearchPath, nil, "Set a custom search_path for the steampipe user for a query session (comma-separated)").
    97  		AddStringSliceFlag(constants.ArgSearchPathPrefix, nil, "Set a prefix to the current search path for a query session (comma-separated)").
    98  		AddStringSliceFlag(constants.ArgVarFile, nil, "Specify a file containing variable values").
    99  		// NOTE: use StringArrayFlag for ArgVariable, not StringSliceFlag
   100  		// Cobra will interpret values passed to a StringSliceFlag as CSV,
   101  		// where args passed to StringArrayFlag are not parsed and used raw
   102  		AddStringArrayFlag(constants.ArgVariable, nil, "Specify the value of a variable").
   103  		AddBoolFlag(constants.ArgInput, true, "Enable interactive prompts").
   104  		AddBoolFlag(constants.ArgSnapshot, false, "Create snapshot in Turbot Pipes with the default (workspace) visibility").
   105  		AddBoolFlag(constants.ArgShare, false, "Create snapshot in Turbot Pipes with 'anyone_with_link' visibility").
   106  		AddStringArrayFlag(constants.ArgSnapshotTag, nil, "Specify tags to set on the snapshot").
   107  		AddStringFlag(constants.ArgSnapshotTitle, "", "The title to give a snapshot").
   108  		AddIntFlag(constants.ArgDatabaseQueryTimeout, 0, "The query timeout").
   109  		AddStringSliceFlag(constants.ArgExport, nil, "Export output to file, supported format: sps (snapshot)").
   110  		AddStringFlag(constants.ArgSnapshotLocation, "", "The location to write snapshots - either a local file path or a Turbot Pipes workspace").
   111  		AddBoolFlag(constants.ArgProgress, true, "Display snapshot upload status")
   112  
   113  	cmd.AddCommand(getListSubCmd(listSubCmdOptions{parentCmd: cmd}))
   114  
   115  	return cmd
   116  }
   117  
   118  func runQueryCmd(cmd *cobra.Command, args []string) {
   119  	ctx := cmd.Context()
   120  	utils.LogTime("cmd.runQueryCmd start")
   121  	defer func() {
   122  		utils.LogTime("cmd.runQueryCmd end")
   123  		if r := recover(); r != nil {
   124  			error_helpers.ShowError(ctx, helpers.ToError(r))
   125  		}
   126  	}()
   127  
   128  	// validate args
   129  	err := validateQueryArgs(ctx, args)
   130  	error_helpers.FailOnError(err)
   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  	if len(args) == 0 {
   139  		// no positional arguments - check if there's anything on stdin
   140  		if stdinData := getPipedStdinData(); len(stdinData) > 0 {
   141  			// we have data - treat this as an argument
   142  			args = append(args, stdinData)
   143  		}
   144  	}
   145  
   146  	// enable paging only in interactive mode
   147  	interactiveMode := len(args) == 0
   148  	// set config to indicate whether we are running an interactive query
   149  	viper.Set(constants.ConfigKeyInteractive, interactiveMode)
   150  
   151  	// initialize the cancel handler - for context cancellation
   152  	initCtx, cancel := context.WithCancel(ctx)
   153  	contexthelpers.StartCancelHandler(cancel)
   154  
   155  	// start the initializer
   156  	initData := query.NewInitData(initCtx, args)
   157  	if initData.Result.Error != nil {
   158  		exitCode = constants.ExitCodeInitializationFailed
   159  		error_helpers.ShowError(ctx, initData.Result.Error)
   160  		return
   161  	}
   162  	defer initData.Cleanup(ctx)
   163  
   164  	var failures int
   165  	switch {
   166  	case interactiveMode:
   167  		err = queryexecute.RunInteractiveSession(ctx, initData)
   168  	case snapshotRequired():
   169  		// if we are either outputting snapshot format, or sharing the results as a snapshot, execute the query
   170  		// as a dashboard
   171  		failures = executeSnapshotQuery(initData, ctx)
   172  	default:
   173  		// NOTE: disable any status updates - we do not want 'loading' output from any queries
   174  		ctx = statushooks.DisableStatusHooks(ctx)
   175  
   176  		// fall through to running a batch query
   177  		failures, err = queryexecute.RunBatchSession(ctx, initData)
   178  	}
   179  
   180  	// check for err and set the exit code else set the exit code if some queries failed or some rows returned an error
   181  	if err != nil {
   182  		exitCode = constants.ExitCodeInitializationFailed
   183  		error_helpers.ShowError(ctx, err)
   184  	} else if failures > 0 {
   185  		exitCode = constants.ExitCodeQueryExecutionFailed
   186  	}
   187  }
   188  
   189  func validateQueryArgs(ctx context.Context, args []string) error {
   190  	interactiveMode := len(args) == 0
   191  	if interactiveMode && (viper.IsSet(constants.ArgSnapshot) || viper.IsSet(constants.ArgShare)) {
   192  		exitCode = constants.ExitCodeInsufficientOrWrongInputs
   193  		return sperr.New("cannot share snapshots in interactive mode")
   194  	}
   195  	if interactiveMode && len(viper.GetStringSlice(constants.ArgExport)) > 0 {
   196  		exitCode = constants.ExitCodeInsufficientOrWrongInputs
   197  		return sperr.New("cannot export query results in interactive mode")
   198  	}
   199  	// if share or snapshot args are set, there must be a query specified
   200  	err := cmdconfig.ValidateSnapshotArgs(ctx)
   201  	if err != nil {
   202  		exitCode = constants.ExitCodeInsufficientOrWrongInputs
   203  		return err
   204  	}
   205  
   206  	validOutputFormats := []string{constants.OutputFormatLine, constants.OutputFormatCSV, constants.OutputFormatTable, constants.OutputFormatJSON, constants.OutputFormatSnapshot, constants.OutputFormatSnapshotShort, constants.OutputFormatNone}
   207  	output := viper.GetString(constants.ArgOutput)
   208  	if !helpers.StringSliceContains(validOutputFormats, output) {
   209  		exitCode = constants.ExitCodeInsufficientOrWrongInputs
   210  		return sperr.New("invalid output format: '%s', must be one of [%s]", output, strings.Join(validOutputFormats, ", "))
   211  	}
   212  
   213  	return nil
   214  }
   215  
   216  func executeSnapshotQuery(initData *query.InitData, ctx context.Context) int {
   217  	// start cancel handler to intercept interrupts and cancel the context
   218  	// NOTE: use the initData Cancel function to ensure any initialisation is cancelled if needed
   219  	contexthelpers.StartCancelHandler(initData.Cancel)
   220  
   221  	// wait for init
   222  	<-initData.Loaded
   223  	if err := initData.Result.Error; err != nil {
   224  		exitCode = constants.ExitCodeInitializationFailed
   225  		error_helpers.FailOnError(err)
   226  	}
   227  
   228  	// if there is a custom search path, wait until the first connection of each plugin has loaded
   229  	if customSearchPath := initData.Client.GetCustomSearchPath(); customSearchPath != nil {
   230  		if err := connection_sync.WaitForSearchPathSchemas(ctx, initData.Client, customSearchPath); err != nil {
   231  			exitCode = constants.ExitCodeInitializationFailed
   232  			error_helpers.FailOnError(err)
   233  		}
   234  	}
   235  
   236  	for _, resolvedQuery := range initData.Queries {
   237  		// if a manual query is being run (i.e. not a named query), convert into a query and add to workspace
   238  		// this is to allow us to use existing dashboard execution code
   239  		queryProvider, existingResource := ensureSnapshotQueryResource(resolvedQuery.Name, resolvedQuery, initData.Workspace)
   240  
   241  		// we need to pass the embedded initData to  GenerateSnapshot
   242  		baseInitData := &initData.InitData
   243  
   244  		// so a dashboard name was specified - just call GenerateSnapshot
   245  		snap, err := dashboardexecute.GenerateSnapshot(ctx, queryProvider.Name(), baseInitData, nil)
   246  		if err != nil {
   247  			exitCode = constants.ExitCodeSnapshotCreationFailed
   248  			error_helpers.FailOnError(err)
   249  		}
   250  
   251  		// set the filename root for the snapshot (in case needed)
   252  		if !existingResource {
   253  			snap.FileNameRoot = "query"
   254  		}
   255  
   256  		// display the result
   257  		switch viper.GetString(constants.ArgOutput) {
   258  		case constants.OutputFormatNone:
   259  			// do nothing
   260  		case constants.OutputFormatSnapshot, constants.OutputFormatSnapshotShort:
   261  			// if the format is snapshot, just dump it out
   262  			jsonOutput, err := json.MarshalIndent(snap, "", "  ")
   263  			if err != nil {
   264  				error_helpers.FailOnErrorWithMessage(err, "failed to display result as snapshot")
   265  			}
   266  			fmt.Println(string(jsonOutput))
   267  		default:
   268  			// otherwise convert the snapshot into a query result
   269  			result, err := snapshotToQueryResult(snap)
   270  			error_helpers.FailOnErrorWithMessage(err, "failed to display result as snapshot")
   271  			display.ShowOutput(ctx, result, display.WithTimingDisabled())
   272  		}
   273  
   274  		// share the snapshot if necessary
   275  		err = publishSnapshotIfNeeded(ctx, snap)
   276  		if err != nil {
   277  			exitCode = constants.ExitCodeSnapshotUploadFailed
   278  			error_helpers.FailOnErrorWithMessage(err, fmt.Sprintf("failed to publish snapshot to %s", viper.GetString(constants.ArgSnapshotLocation)))
   279  		}
   280  
   281  		// export the result if necessary
   282  		exportArgs := viper.GetStringSlice(constants.ArgExport)
   283  		exportMsg, err := initData.ExportManager.DoExport(ctx, snap.FileNameRoot, snap, exportArgs)
   284  		error_helpers.FailOnErrorWithMessage(err, "failed to export snapshot")
   285  		// print the location where the file is exported
   286  		if len(exportMsg) > 0 && viper.GetBool(constants.ArgProgress) {
   287  			fmt.Printf("\n")
   288  			fmt.Println(strings.Join(exportMsg, "\n"))
   289  			fmt.Printf("\n")
   290  		}
   291  	}
   292  	return 0
   293  }
   294  
   295  func snapshotToQueryResult(snap *dashboardtypes.SteampipeSnapshot) (*queryresult.Result, error) {
   296  	// the table of a snapshot query has a fixed name
   297  	tablePanel, ok := snap.Panels[modconfig.SnapshotQueryTableName]
   298  	if !ok {
   299  		return nil, sperr.New("dashboard does not contain table result for query")
   300  	}
   301  	chartRun := tablePanel.(*dashboardexecute.LeafRun)
   302  	if !ok {
   303  		return nil, sperr.New("failed to read query result from snapshot")
   304  	}
   305  	// check for error
   306  	if err := chartRun.GetError(); err != nil {
   307  		return nil, error_helpers.DecodePgError(err)
   308  	}
   309  
   310  	res := queryresult.NewResult(chartRun.Data.Columns)
   311  
   312  	// start a goroutine to stream the results as rows
   313  	go func() {
   314  		for _, d := range chartRun.Data.Rows {
   315  			// we need to allocate a new slice everytime, since this gets read
   316  			// asynchronously on the other end and we need to make sure that we don't overwrite
   317  			// data already sent
   318  			rowVals := make([]interface{}, len(chartRun.Data.Columns))
   319  			for i, c := range chartRun.Data.Columns {
   320  				rowVals[i] = d[c.Name]
   321  			}
   322  			res.StreamRow(rowVals)
   323  		}
   324  		res.TimingResult <- chartRun.TimingResult
   325  		res.Close()
   326  	}()
   327  
   328  	return res, nil
   329  }
   330  
   331  // convert the given command line query into a query resource and add to workspace
   332  // this is to allow us to use existing dashboard execution code
   333  func ensureSnapshotQueryResource(name string, resolvedQuery *modconfig.ResolvedQuery, w *workspace.Workspace) (queryProvider modconfig.HclResource, existingResource bool) {
   334  	// is this an existing resource?
   335  	if parsedName, err := modconfig.ParseResourceName(name); err == nil {
   336  		if resource, found := w.GetResource(parsedName); found {
   337  			return resource, true
   338  		}
   339  	}
   340  
   341  	// build name
   342  	shortName := "command_line_query"
   343  
   344  	// this is NOT a named query - create the query using RawSql
   345  	q := modconfig.NewQuery(&hcl.Block{Type: modconfig.BlockTypeQuery}, w.Mod, shortName).(*modconfig.Query)
   346  	q.SQL = utils.ToStringPointer(resolvedQuery.RawSQL)
   347  	q.SetArgs(resolvedQuery.QueryArgs())
   348  	// add empty metadata
   349  	q.SetMetadata(&modconfig.ResourceMetadata{})
   350  
   351  	// add to the workspace mod so the dashboard execution code can find it
   352  	w.Mod.AddResource(q)
   353  	// return the new resource name
   354  	return q, false
   355  }
   356  
   357  func snapshotRequired() bool {
   358  	SnapshotFormatNames := []string{constants.OutputFormatSnapshot, constants.OutputFormatSnapshotShort}
   359  	// if a snapshot exporter is specified return true
   360  	for _, e := range viper.GetStringSlice(constants.ArgExport) {
   361  		if helpers.StringSliceContains(SnapshotFormatNames, e) || path.Ext(e) == constants.SnapshotExtension {
   362  			return true
   363  		}
   364  	}
   365  	// if share/snapshot args are set or output is snapshot, return true
   366  	return viper.IsSet(constants.ArgShare) ||
   367  		viper.IsSet(constants.ArgSnapshot) ||
   368  		helpers.StringSliceContains(SnapshotFormatNames, viper.GetString(constants.ArgOutput))
   369  
   370  }
   371  
   372  // getPipedStdinData reads the Standard Input and returns the available data as a string
   373  // if and only if the data was piped to the process
   374  func getPipedStdinData() string {
   375  	fi, err := os.Stdin.Stat()
   376  	if err != nil {
   377  		error_helpers.ShowWarning("could not fetch information about STDIN")
   378  		return ""
   379  	}
   380  	stdinData := ""
   381  	if (fi.Mode()&os.ModeCharDevice) == 0 && fi.Size() > 0 {
   382  		scanner := bufio.NewScanner(os.Stdin)
   383  		for scanner.Scan() {
   384  			stdinData = fmt.Sprintf("%s%s", stdinData, scanner.Text())
   385  		}
   386  	}
   387  	return stdinData
   388  }