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 }