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  }