github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/cmd/dashboard.go (about) 1 package cmd 2 3 import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log" 8 "os" 9 "strings" 10 11 "github.com/spf13/cobra" 12 "github.com/spf13/viper" 13 "github.com/turbot/go-kit/helpers" 14 "github.com/turbot/steampipe-plugin-sdk/v5/logging" 15 "github.com/turbot/steampipe/pkg/cloud" 16 "github.com/turbot/steampipe/pkg/cmdconfig" 17 "github.com/turbot/steampipe/pkg/constants" 18 "github.com/turbot/steampipe/pkg/contexthelpers" 19 "github.com/turbot/steampipe/pkg/control/controlstatus" 20 "github.com/turbot/steampipe/pkg/dashboard/dashboardassets" 21 "github.com/turbot/steampipe/pkg/dashboard/dashboardexecute" 22 "github.com/turbot/steampipe/pkg/dashboard/dashboardserver" 23 "github.com/turbot/steampipe/pkg/dashboard/dashboardtypes" 24 "github.com/turbot/steampipe/pkg/error_helpers" 25 "github.com/turbot/steampipe/pkg/export" 26 "github.com/turbot/steampipe/pkg/initialisation" 27 "github.com/turbot/steampipe/pkg/statushooks" 28 "github.com/turbot/steampipe/pkg/steampipeconfig/modconfig" 29 "github.com/turbot/steampipe/pkg/utils" 30 "github.com/turbot/steampipe/pkg/workspace" 31 ) 32 33 func dashboardCmd() *cobra.Command { 34 cmd := &cobra.Command{ 35 Use: "dashboard [flags] [benchmark/dashboard]", 36 TraverseChildren: true, 37 Args: cobra.ArbitraryArgs, 38 Run: runDashboardCmd, 39 Short: "Start the local dashboard UI or run a named dashboard", 40 Long: `Either runs the a named dashboard or benchmark, or starts a local web server that enables real-time development of dashboards within the current mod. 41 42 The current mod is the working directory, or the directory specified by the --mod-location flag.`, 43 } 44 45 cmdconfig.OnCmd(cmd). 46 AddCloudFlags(). 47 AddWorkspaceDatabaseFlag(). 48 AddModLocationFlag(). 49 AddBoolFlag(constants.ArgHelp, false, "Help for dashboard", cmdconfig.FlagOptions.WithShortHand("h")). 50 AddBoolFlag(constants.ArgModInstall, true, "Specify whether to install mod dependencies before running the dashboard"). 51 AddStringFlag(constants.ArgDashboardListen, string(dashboardserver.ListenTypeLocal), "Accept connections from: local (localhost only) or network (open)"). 52 AddIntFlag(constants.ArgDashboardPort, constants.DashboardServerDefaultPort, "Dashboard server port"). 53 AddBoolFlag(constants.ArgBrowser, true, "Specify whether to launch the browser after starting the dashboard server"). 54 AddStringSliceFlag(constants.ArgSearchPath, nil, "Set a custom search_path for the steampipe user for a dashboard session (comma-separated)"). 55 AddStringSliceFlag(constants.ArgSearchPathPrefix, nil, "Set a prefix to the current search path for a dashboard session (comma-separated)"). 56 AddIntFlag(constants.ArgMaxParallel, constants.DefaultMaxConnections, "The maximum number of concurrent database connections to open"). 57 AddStringSliceFlag(constants.ArgVarFile, nil, "Specify an .spvar file containing variable values"). 58 AddBoolFlag(constants.ArgProgress, true, "Display dashboard execution progress respected when a dashboard name argument is passed"). 59 // NOTE: use StringArrayFlag for ArgVariable, not StringSliceFlag 60 // Cobra will interpret values passed to a StringSliceFlag as CSV, where args passed to StringArrayFlag are not parsed and used raw 61 AddStringArrayFlag(constants.ArgVariable, nil, "Specify the value of a variable"). 62 AddBoolFlag(constants.ArgInput, true, "Enable interactive prompts"). 63 AddStringFlag(constants.ArgOutput, constants.OutputFormatNone, "Select a console output format: none, snapshot"). 64 AddBoolFlag(constants.ArgSnapshot, false, "Create snapshot in Turbot Pipes with the default (workspace) visibility"). 65 AddBoolFlag(constants.ArgShare, false, "Create snapshot in Turbot Pipes with 'anyone_with_link' visibility"). 66 AddStringFlag(constants.ArgSnapshotLocation, "", "The location to write snapshots - either a local file path or a Turbot Pipes workspace"). 67 AddStringFlag(constants.ArgSnapshotTitle, "", "The title to give a snapshot"). 68 // NOTE: use StringArrayFlag for ArgDashboardInput, not StringSliceFlag 69 // Cobra will interpret values passed to a StringSliceFlag as CSV, where args passed to StringArrayFlag are not parsed and used raw 70 AddStringArrayFlag(constants.ArgDashboardInput, nil, "Specify the value of a dashboard input"). 71 AddStringArrayFlag(constants.ArgSnapshotTag, nil, "Specify tags to set on the snapshot"). 72 AddStringSliceFlag(constants.ArgExport, nil, "Export output to file, supported format: sps (snapshot)"). 73 // hidden flags that are used internally 74 AddBoolFlag(constants.ArgServiceMode, false, "Hidden flag to specify whether this is starting as a service", cmdconfig.FlagOptions.Hidden()) 75 76 cmd.AddCommand(getListSubCmd(listSubCmdOptions{parentCmd: cmd})) 77 78 return cmd 79 } 80 81 func runDashboardCmd(cmd *cobra.Command, args []string) { 82 dashboardCtx := cmd.Context() 83 84 var err error 85 logging.LogTime("runDashboardCmd start") 86 defer func() { 87 logging.LogTime("runDashboardCmd end") 88 if r := recover(); r != nil { 89 err = helpers.ToError(r) 90 error_helpers.ShowError(dashboardCtx, err) 91 if isRunningAsService() { 92 saveErrorToDashboardState(err) 93 } 94 } 95 setExitCodeForDashboardError(err) 96 }() 97 98 // first check whether a dashboard name has been passed as an arg 99 dashboardName, err := validateDashboardArgs(dashboardCtx, args) 100 error_helpers.FailOnError(err) 101 102 // if diagnostic mode is set, print out config and return 103 if _, ok := os.LookupEnv(constants.EnvConfigDump); ok { 104 cmdconfig.DisplayConfig() 105 return 106 } 107 108 if dashboardName != "" { 109 inputs, err := collectInputs() 110 error_helpers.FailOnError(err) 111 112 // run just this dashboard - this handles all initialisation 113 err = runSingleDashboard(dashboardCtx, dashboardName, inputs) 114 error_helpers.FailOnError(err) 115 116 // and we are done 117 return 118 } 119 120 // retrieve server params 121 serverPort := dashboardserver.ListenPort(viper.GetInt(constants.ArgDashboardPort)) 122 error_helpers.FailOnError(serverPort.IsValid()) 123 124 serverListen := dashboardserver.ListenType(viper.GetString(constants.ArgDashboardListen)) 125 error_helpers.FailOnError(serverListen.IsValid()) 126 127 serverHost := "" 128 if serverListen == dashboardserver.ListenTypeLocal { 129 serverHost = "127.0.0.1" 130 } 131 if err := utils.IsPortBindable(serverHost, int(serverPort)); err != nil { 132 exitCode = constants.ExitCodeBindPortUnavailable 133 error_helpers.FailOnError(err) 134 } 135 136 // create context for the dashboard execution 137 dashboardCtx, cancel := context.WithCancel(dashboardCtx) 138 contexthelpers.StartCancelHandler(cancel) 139 140 // ensure dashboard assets are present and extract if not 141 err = dashboardassets.Ensure(dashboardCtx) 142 error_helpers.FailOnError(err) 143 144 // disable all status messages 145 dashboardCtx = statushooks.DisableStatusHooks(dashboardCtx) 146 147 // load the workspace 148 initData := initDashboard(dashboardCtx) 149 defer initData.Cleanup(dashboardCtx) 150 if initData.Result.Error != nil { 151 exitCode = constants.ExitCodeInitializationFailed 152 error_helpers.FailOnError(initData.Result.Error) 153 } 154 155 // if there is a usage warning we display it 156 initData.Result.DisplayMessage = dashboardserver.OutputMessage 157 initData.Result.DisplayWarning = dashboardserver.OutputWarning 158 initData.Result.DisplayMessages() 159 160 // create the server 161 server, err := dashboardserver.NewServer(dashboardCtx, initData.Client, initData.Workspace) 162 error_helpers.FailOnError(err) 163 164 // start the server asynchronously - this returns a chan which is signalled when the internal API server terminates 165 doneChan := server.Start(dashboardCtx) 166 167 // cleanup 168 defer server.Shutdown(dashboardCtx) 169 170 // server has started - update state file/start browser, as required 171 onServerStarted(dashboardCtx, serverPort, serverListen, initData.Workspace) 172 173 // wait for API server to terminate 174 <-doneChan 175 176 log.Println("[TRACE] runDashboardCmd exiting") 177 } 178 179 // validate the args and extract a dashboard name, if provided 180 func validateDashboardArgs(ctx context.Context, args []string) (string, error) { 181 if len(args) > 1 { 182 return "", fmt.Errorf("dashboard command accepts 0 or 1 argument") 183 } 184 dashboardName := "" 185 if len(args) == 1 { 186 dashboardName = args[0] 187 } 188 189 err := cmdconfig.ValidateSnapshotArgs(ctx) 190 if err != nil { 191 return "", err 192 } 193 194 // only 1 of 'share' and 'snapshot' may be set 195 share := viper.GetBool(constants.ArgShare) 196 snapshot := viper.GetBool(constants.ArgSnapshot) 197 198 // if either share' or 'snapshot' are set, a dashboard name 199 if share || snapshot { 200 if dashboardName == "" { 201 return "", fmt.Errorf("dashboard name must be provided if --share or --snapshot arg is used") 202 } 203 } 204 205 validOutputFormats := []string{constants.OutputFormatSnapshot, constants.OutputFormatSnapshotShort, constants.OutputFormatNone} 206 output := viper.GetString(constants.ArgOutput) 207 if !helpers.StringSliceContains(validOutputFormats, output) { 208 return "", fmt.Errorf("invalid output format: '%s', must be one of [%s]", output, strings.Join(validOutputFormats, ", ")) 209 } 210 211 return dashboardName, nil 212 } 213 214 func displaySnapshot(snapshot *dashboardtypes.SteampipeSnapshot) { 215 switch viper.GetString(constants.ArgOutput) { 216 case constants.OutputFormatNone: 217 if viper.GetBool(constants.ArgProgress) && 218 !viper.IsSet(constants.ArgOutput) && 219 !viper.GetBool(constants.ArgShare) && 220 !viper.GetBool(constants.ArgSnapshot) { 221 fmt.Println("Output format defaulted to 'none'. Supported formats: none, snapshot.") 222 } 223 case constants.OutputFormatSnapshot, constants.OutputFormatSnapshotShort: 224 // just display result 225 snapshotText, err := json.MarshalIndent(snapshot, "", " ") 226 error_helpers.FailOnError(err) 227 fmt.Println(string(snapshotText)) 228 } 229 } 230 231 func initDashboard(ctx context.Context) *initialisation.InitData { 232 dashboardserver.OutputWait(ctx, "Loading Workspace") 233 234 // initialise 235 initData := getInitData(ctx) 236 if initData.Result.Error != nil { 237 return initData 238 } 239 240 // there must be a mod-file 241 if !initData.Workspace.ModfileExists() { 242 initData.Result.Error = workspace.ErrorNoModDefinition 243 } 244 245 return initData 246 } 247 248 func getInitData(ctx context.Context) *initialisation.InitData { 249 w, errAndWarnings := workspace.LoadWorkspacePromptingForVariables(ctx) 250 if errAndWarnings.GetError() != nil { 251 return initialisation.NewErrorInitData(fmt.Errorf("failed to load workspace: %s", error_helpers.HandleCancelError(errAndWarnings.GetError()).Error())) 252 } 253 254 i := initialisation.NewInitData() 255 i.Workspace = w 256 i.Result.Warnings = errAndWarnings.Warnings 257 i.Init(ctx, constants.InvokerDashboard) 258 259 if len(viper.GetStringSlice(constants.ArgExport)) > 0 { 260 i.RegisterExporters(dashboardExporters()...) 261 // validate required export formats 262 if err := i.ExportManager.ValidateExportFormat(viper.GetStringSlice(constants.ArgExport)); err != nil { 263 i.Result.Error = err 264 return i 265 } 266 } 267 268 return i 269 } 270 271 func dashboardExporters() []export.Exporter { 272 return []export.Exporter{&export.SnapshotExporter{}} 273 } 274 275 func runSingleDashboard(ctx context.Context, targetName string, inputs map[string]interface{}) error { 276 // create context for the dashboard execution 277 ctx = createSnapshotContext(ctx, targetName) 278 279 statushooks.SetStatus(ctx, "Initializing…") 280 initData := getInitData(ctx) 281 282 statushooks.Done(ctx) 283 284 // shutdown the service on exit 285 defer initData.Cleanup(ctx) 286 if err := initData.Result.Error; err != nil { 287 return initData.Result.Error 288 } 289 // targetName must be a named resource 290 // parse the name to verify 291 targetResource, err := verifyNamedResource(targetName, initData.Workspace) 292 if err != nil { 293 return err 294 } 295 // update name to make sure it is fully qualified 296 targetName = targetResource.Name() 297 298 // if there is a usage warning we display it 299 initData.Result.DisplayMessages() 300 301 // so a dashboard name was specified - just call GenerateSnapshot 302 snap, err := dashboardexecute.GenerateSnapshot(ctx, targetName, initData, inputs) 303 if err != nil { 304 exitCode = constants.ExitCodeSnapshotCreationFailed 305 return err 306 } 307 // display the snapshot result (if needed) 308 displaySnapshot(snap) 309 310 // upload the snapshot (if needed) 311 err = publishSnapshotIfNeeded(ctx, snap) 312 if err != nil { 313 exitCode = constants.ExitCodeSnapshotUploadFailed 314 error_helpers.FailOnErrorWithMessage(err, fmt.Sprintf("failed to publish snapshot to %s", viper.GetString(constants.ArgSnapshotLocation))) 315 } 316 317 // export the result (if needed) 318 exportArgs := viper.GetStringSlice(constants.ArgExport) 319 exportMsg, err := initData.ExportManager.DoExport(ctx, snap.FileNameRoot, snap, exportArgs) 320 error_helpers.FailOnErrorWithMessage(err, "failed to export snapshot") 321 322 // print the location where the file is exported 323 if len(exportMsg) > 0 && viper.GetBool(constants.ArgProgress) { 324 fmt.Printf("\n") 325 fmt.Println(strings.Join(exportMsg, "\n")) 326 fmt.Printf("\n") 327 } 328 329 return nil 330 } 331 332 func verifyNamedResource(targetName string, w *workspace.Workspace) (modconfig.HclResource, error) { 333 parsedName, err := modconfig.ParseResourceName(targetName) 334 if err != nil { 335 return nil, fmt.Errorf("dashboard command cannot run arbitrary SQL") 336 } 337 if parsedName.ItemType == "" { 338 return nil, fmt.Errorf("dashboard command cannot run arbitrary SQL") 339 } 340 if r, found := w.GetResource(parsedName); !found { 341 return nil, fmt.Errorf("'%s' not found in %s (%s)", targetName, w.Mod.Name(), w.Path) 342 } else { 343 return r, nil 344 } 345 } 346 347 func publishSnapshotIfNeeded(ctx context.Context, snapshot *dashboardtypes.SteampipeSnapshot) error { 348 shouldShare := viper.GetBool(constants.ArgShare) 349 shouldUpload := viper.GetBool(constants.ArgSnapshot) 350 351 if !(shouldShare || shouldUpload) { 352 return nil 353 } 354 355 message, err := cloud.PublishSnapshot(ctx, snapshot, shouldShare) 356 if err != nil { 357 // reword "402 Payment Required" error 358 return handlePublishSnapshotError(err) 359 } 360 if viper.GetBool(constants.ArgProgress) { 361 fmt.Println(message) 362 } 363 return nil 364 } 365 366 func handlePublishSnapshotError(err error) error { 367 if err.Error() == "402 Payment Required" { 368 return fmt.Errorf("maximum number of snapshots reached") 369 } 370 return err 371 } 372 373 func setExitCodeForDashboardError(err error) { 374 // if exit code already set, leave as is 375 if exitCode != 0 || err == nil { 376 return 377 } 378 379 if err == workspace.ErrorNoModDefinition { 380 exitCode = constants.ExitCodeNoModFile 381 } else { 382 exitCode = constants.ExitCodeUnknownErrorPanic 383 } 384 } 385 386 // execute any required actions after successful server startup 387 func onServerStarted(ctx context.Context, serverPort dashboardserver.ListenPort, serverListen dashboardserver.ListenType, w *workspace.Workspace) { 388 if isRunningAsService() { 389 // for service mode only, save the state 390 saveDashboardState(serverPort, serverListen) 391 } else { 392 // start browser if required 393 if viper.GetBool(constants.ArgBrowser) { 394 url := buildDashboardURL(serverPort, w) 395 if err := utils.OpenBrowser(url); err != nil { 396 dashboardserver.OutputWarning(ctx, "Could not start web browser.") 397 log.Println("[TRACE] dashboard server started but failed to start client", err) 398 } 399 } 400 } 401 } 402 403 func buildDashboardURL(serverPort dashboardserver.ListenPort, w *workspace.Workspace) string { 404 url := fmt.Sprintf("http://localhost:%d", serverPort) 405 if len(w.SourceSnapshots) == 1 { 406 for snapshotName := range w.GetResourceMaps().Snapshots { 407 url += fmt.Sprintf("/%s", snapshotName) 408 break 409 } 410 } 411 return url 412 } 413 414 // is this dashboard server running as a service? 415 func isRunningAsService() bool { 416 return viper.GetBool(constants.ArgServiceMode) 417 } 418 419 // persist the error to the dashboard state file 420 func saveErrorToDashboardState(err error) { 421 state, _ := dashboardserver.GetDashboardServiceState() 422 if state == nil { 423 // write the state file with an error, only if it doesn't exist already 424 // if it exists, that means dashboard stated properly and 'service start' already known about it 425 state = &dashboardserver.DashboardServiceState{ 426 State: dashboardserver.ServiceStateError, 427 Error: err.Error(), 428 } 429 dashboardserver.WriteServiceStateFile(state) 430 } 431 } 432 433 // save the dashboard state file 434 func saveDashboardState(serverPort dashboardserver.ListenPort, serverListen dashboardserver.ListenType) { 435 state := &dashboardserver.DashboardServiceState{ 436 State: dashboardserver.ServiceStateRunning, 437 Error: "", 438 Pid: os.Getpid(), 439 Port: int(serverPort), 440 ListenType: string(serverListen), 441 Listen: constants.DashboardListenAddresses, 442 } 443 444 if serverListen == dashboardserver.ListenTypeNetwork { 445 addrs, _ := utils.LocalPublicAddresses() 446 state.Listen = append(state.Listen, addrs...) 447 } 448 error_helpers.FailOnError(dashboardserver.WriteServiceStateFile(state)) 449 } 450 451 func collectInputs() (map[string]interface{}, error) { 452 res := make(map[string]interface{}) 453 inputArgs := viper.GetStringSlice(constants.ArgDashboardInput) 454 for _, variableArg := range inputArgs { 455 // Value should be in the form "name=value", where value is a string 456 raw := variableArg 457 eq := strings.Index(raw, "=") 458 if eq == -1 { 459 return nil, fmt.Errorf("the --dashboard-input argument '%s' is not correctly specified. It must be an input name and value separated an equals sign: --dashboard-input key=value", raw) 460 } 461 name := raw[:eq] 462 rawVal := raw[eq+1:] 463 if _, ok := res[name]; ok { 464 return nil, fmt.Errorf("the dashboard-input option '%s' is provided more than once", name) 465 } 466 // add `input. to start of name 467 key := modconfig.BuildModResourceName(modconfig.BlockTypeInput, name) 468 res[key] = rawVal 469 } 470 471 return res, nil 472 473 } 474 475 // create the context for the dashboard run - add a control status renderer 476 func createSnapshotContext(ctx context.Context, target string) context.Context { 477 // create context for the dashboard execution 478 snapshotCtx, cancel := context.WithCancel(ctx) 479 contexthelpers.StartCancelHandler(cancel) 480 481 // if progress is disabled, OR output is none, do not show status hooks 482 if !viper.GetBool(constants.ArgProgress) { 483 snapshotCtx = statushooks.DisableStatusHooks(snapshotCtx) 484 } 485 486 snapshotProgressReporter := statushooks.NewSnapshotProgressReporter(target) 487 snapshotCtx = statushooks.AddSnapshotProgressToContext(snapshotCtx, snapshotProgressReporter) 488 489 // create a context with a SnapshotControlHooks to report execution progress of any controls in this snapshot 490 snapshotCtx = controlstatus.AddControlHooksToContext(snapshotCtx, controlstatus.NewSnapshotControlHooks()) 491 return snapshotCtx 492 }