github.com/turbot/steampipe@v1.7.0-rc.0.0.20240517123944-7cef272d4458/pkg/cmdconfig/cmd_hooks.go (about)

     1  package cmdconfig
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"io"
     8  	"log"
     9  	"os"
    10  	"runtime/debug"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/fatih/color"
    15  	"github.com/hashicorp/go-hclog"
    16  	"github.com/mattn/go-isatty"
    17  	"github.com/spf13/cobra"
    18  	"github.com/spf13/viper"
    19  	filehelpers "github.com/turbot/go-kit/files"
    20  	"github.com/turbot/go-kit/helpers"
    21  	"github.com/turbot/go-kit/logging"
    22  	sdklogging "github.com/turbot/steampipe-plugin-sdk/v5/logging"
    23  	"github.com/turbot/steampipe-plugin-sdk/v5/plugin"
    24  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    25  	"github.com/turbot/steampipe/pkg/cloud"
    26  	"github.com/turbot/steampipe/pkg/constants"
    27  	"github.com/turbot/steampipe/pkg/constants/runtime"
    28  	"github.com/turbot/steampipe/pkg/error_helpers"
    29  	"github.com/turbot/steampipe/pkg/filepaths"
    30  	"github.com/turbot/steampipe/pkg/ociinstaller/versionfile"
    31  	"github.com/turbot/steampipe/pkg/steampipeconfig"
    32  	"github.com/turbot/steampipe/pkg/task"
    33  	"github.com/turbot/steampipe/pkg/utils"
    34  	"github.com/turbot/steampipe/pkg/version"
    35  )
    36  
    37  var waitForTasksChannel chan struct{}
    38  var tasksCancelFn context.CancelFunc
    39  
    40  // postRunHook is a function that is executed after the PostRun of every command handler
    41  func postRunHook(cmd *cobra.Command, args []string) {
    42  	utils.LogTime("cmdhook.postRunHook start")
    43  	defer utils.LogTime("cmdhook.postRunHook end")
    44  
    45  	if waitForTasksChannel != nil {
    46  		// wait for the async tasks to finish
    47  		select {
    48  		case <-time.After(100 * time.Millisecond):
    49  			tasksCancelFn()
    50  			return
    51  		case <-waitForTasksChannel:
    52  			return
    53  		}
    54  	}
    55  }
    56  
    57  // preRunHook is a function that is executed before the PreRun of every command handler
    58  func preRunHook(cmd *cobra.Command, args []string) {
    59  	utils.LogTime("cmdhook.preRunHook start")
    60  	defer utils.LogTime("cmdhook.preRunHook end")
    61  
    62  	ctx := cmd.Context()
    63  
    64  	viper.Set(constants.ConfigKeyActiveCommand, cmd)
    65  	viper.Set(constants.ConfigKeyActiveCommandArgs, args)
    66  	viper.Set(constants.ConfigKeyIsTerminalTTY, isatty.IsTerminal(os.Stdout.Fd()))
    67  
    68  	// steampipe completion should not create INSTALL DIR or seup/init global config
    69  	if cmd.Name() == "completion" {
    70  		return
    71  	}
    72  
    73  	// create a buffer which can be used as a sink for log writes
    74  	// till INSTALL_DIR is setup in initGlobalConfig
    75  	logBuffer := bytes.NewBuffer([]byte{})
    76  
    77  	// create a logger before initGlobalConfig - we may need to reinitialize the logger
    78  	// depending on the value of the log_level value in global general options
    79  	createLogger(logBuffer, cmd)
    80  
    81  	// set up the global viper config with default values from
    82  	// config files and ENV variables
    83  	ew := initGlobalConfig()
    84  	// display any warnings
    85  	ew.ShowWarnings()
    86  	// check for error
    87  	error_helpers.FailOnError(ew.Error)
    88  
    89  	// if the log level was set in the general config
    90  	if logLevelNeedsReset() {
    91  		logLevel := viper.GetString(constants.ArgLogLevel)
    92  		// set my environment to the desired log level
    93  		// so that this gets inherited by any other process
    94  		// started by this process (postgres/plugin-manager)
    95  		error_helpers.FailOnErrorWithMessage(
    96  			os.Setenv(sdklogging.EnvLogLevel, logLevel),
    97  			"Failed to setup logging",
    98  		)
    99  	}
   100  
   101  	// recreate the logger
   102  	// this will put the new log level (if any) to effect as well as start streaming to the
   103  	// log file.
   104  	createLogger(logBuffer, cmd)
   105  
   106  	// runScheduledTasks skips running tasks if this instance is the plugin manager
   107  	waitForTasksChannel = runScheduledTasks(ctx, cmd, args, ew)
   108  
   109  	// ensure all plugin installation directories have a version.json file
   110  	// (this is to handle the case of migrating an existing installation from v0.20.x)
   111  	// no point doing this for the plugin-manager since that would have been done by the initiating CLI process
   112  	if !task.IsPluginManagerCmd(cmd) {
   113  		err := versionfile.EnsureVersionFilesInPluginDirectories(ctx)
   114  		error_helpers.FailOnError(sperr.WrapWithMessage(err, "failed to ensure version files in plugin directories"))
   115  	}
   116  
   117  	// set the max memory if specified
   118  	setMemoryLimit()
   119  }
   120  
   121  func setMemoryLimit() {
   122  	maxMemoryBytes := viper.GetInt64(constants.ArgMemoryMaxMb) * 1024 * 1024
   123  	if maxMemoryBytes > 0 {
   124  		// set the max memory
   125  		debug.SetMemoryLimit(maxMemoryBytes)
   126  	}
   127  }
   128  
   129  // runScheduledTasks runs the task runner and returns a channel which is closed when
   130  // task run is complete
   131  //
   132  // runScheduledTasks skips running tasks if this instance is the plugin manager
   133  func runScheduledTasks(ctx context.Context, cmd *cobra.Command, args []string, ew error_helpers.ErrorAndWarnings) chan struct{} {
   134  	// skip running the task runner if this is the plugin manager
   135  	// since it's supposed to be a daemon
   136  	if task.IsPluginManagerCmd(cmd) {
   137  		return nil
   138  	}
   139  
   140  	// display deprecation warning for check, mod and dashboard commands
   141  	if task.IsCheckCmd(cmd) || task.IsDashboardCmd(cmd) || task.IsModCmd(cmd) {
   142  		displayPpDeprecationWarning()
   143  	}
   144  
   145  	taskUpdateCtx, cancelFn := context.WithCancel(ctx)
   146  	tasksCancelFn = cancelFn
   147  
   148  	return task.RunTasks(
   149  		taskUpdateCtx,
   150  		cmd,
   151  		args,
   152  		// pass the config value in rather than runRasks querying viper directly - to avoid concurrent map access issues
   153  		// (we can use the update-check viper config here, since initGlobalConfig has already set it up
   154  		// with values from the config files and ENV settings - update-check cannot be set from the command line)
   155  		task.WithUpdateCheck(viper.GetBool(constants.ArgUpdateCheck)),
   156  		// show deprecation warnings
   157  		task.WithPreHook(func(_ context.Context) {
   158  			displayDeprecationWarnings(ew)
   159  		}),
   160  	)
   161  
   162  }
   163  
   164  // the log level will need resetting if
   165  //
   166  //	this process does not have a log level set in it's environment
   167  //	the GlobalConfig has a loglevel set
   168  func logLevelNeedsReset() bool {
   169  	envLogLevelIsSet := envLogLevelSet()
   170  	generalOptionsSet := (steampipeconfig.GlobalConfig.GeneralOptions != nil && steampipeconfig.GlobalConfig.GeneralOptions.LogLevel != nil)
   171  
   172  	return !envLogLevelIsSet && generalOptionsSet
   173  }
   174  
   175  // envLogLevelSet checks whether any of the current or legacy log level env vars are set
   176  func envLogLevelSet() bool {
   177  	_, ok := os.LookupEnv(sdklogging.EnvLogLevel)
   178  	if ok {
   179  		return ok
   180  	}
   181  	// handle legacy env vars
   182  	for _, e := range sdklogging.LegacyLogLevelEnvVars {
   183  		_, ok = os.LookupEnv(e)
   184  		if ok {
   185  			return ok
   186  		}
   187  	}
   188  	return false
   189  }
   190  
   191  // initGlobalConfig reads in config file and ENV variables if set.
   192  func initGlobalConfig() error_helpers.ErrorAndWarnings {
   193  	utils.LogTime("cmdconfig.initGlobalConfig start")
   194  	defer utils.LogTime("cmdconfig.initGlobalConfig end")
   195  
   196  	var cmd = viper.Get(constants.ConfigKeyActiveCommand).(*cobra.Command)
   197  	ctx := cmd.Context()
   198  
   199  	// load workspace profile from the configured install dir
   200  	loader, err := getWorkspaceProfileLoader(ctx)
   201  	if err != nil {
   202  		return error_helpers.NewErrorsAndWarning(err)
   203  	}
   204  
   205  	// set global workspace profile
   206  	steampipeconfig.GlobalWorkspaceProfile = loader.GetActiveWorkspaceProfile()
   207  
   208  	// set-up viper with defaults from the env and default workspace profile
   209  	err = bootstrapViper(loader, cmd)
   210  	if err != nil {
   211  		return error_helpers.NewErrorsAndWarning(err)
   212  	}
   213  
   214  	// set global containing the configured install dir (create directory if needed)
   215  	ensureInstallDir()
   216  
   217  	// load the connection config and HCL options
   218  	config, loadConfigErrorsAndWarnings := steampipeconfig.LoadSteampipeConfig(ctx, viper.GetString(constants.ArgModLocation), cmd.Name())
   219  	if loadConfigErrorsAndWarnings.Error != nil {
   220  		return loadConfigErrorsAndWarnings
   221  	}
   222  
   223  	// store global config
   224  	steampipeconfig.GlobalConfig = config
   225  
   226  	// set viper defaults from this config
   227  	SetDefaultsFromConfig(steampipeconfig.GlobalConfig.ConfigMap())
   228  
   229  	// set the rest of the defaults from ENV
   230  	// ENV takes precedence over any default configuration
   231  	setDefaultsFromEnv()
   232  
   233  	// if an explicit workspace profile was set, add to viper as highest precedence default
   234  	// NOTE: if install_dir/mod_location are set these will already have been passed to viper by BootstrapViper
   235  	// since the "ConfiguredProfile" is passed in through a cmdline flag, it will always take precedence
   236  	if loader.ConfiguredProfile != nil {
   237  		SetDefaultsFromConfig(loader.ConfiguredProfile.ConfigMap(cmd))
   238  	}
   239  
   240  	// handle deprecated cloud-host and cloud-token args and env vars
   241  	ew := handleDeprecations()
   242  	if ew.Error != nil {
   243  		return ew
   244  	}
   245  
   246  	// NOTE: we need to resolve the token separately
   247  	// - that is because we need the resolved value of ArgPipesHost in order to load any saved token
   248  	// and we cannot get this until the other config has been resolved
   249  	err = setCloudTokenDefault(loader)
   250  	if err != nil {
   251  		loadConfigErrorsAndWarnings.Error = err
   252  		return loadConfigErrorsAndWarnings
   253  	}
   254  
   255  	loadConfigErrorsAndWarnings.Merge(ew)
   256  	// now validate all config values have appropriate values
   257  	ew = validateConfig()
   258  	if ew.Error != nil {
   259  		return ew
   260  	}
   261  	loadConfigErrorsAndWarnings.Merge(ew)
   262  
   263  	return loadConfigErrorsAndWarnings
   264  }
   265  
   266  func handleDeprecations() error_helpers.ErrorAndWarnings {
   267  	var ew = error_helpers.ErrorAndWarnings{}
   268  	// if deprecated cloud-token or cloud-host is set, show a warning and copy the value to the new arg
   269  	if viper.IsSet(constants.ArgCloudToken) {
   270  		if viper.IsSet(constants.ArgPipesToken) {
   271  			ew.Error = sperr.New("Only one of flags --%s and --%s may be set", constants.ArgCloudToken, constants.ArgPipesToken)
   272  			return ew
   273  		}
   274  		viper.Set(constants.ArgPipesToken, viper.GetString(constants.ArgCloudToken))
   275  	}
   276  	if viper.IsSet(constants.ArgCloudHost) {
   277  		if viper.IsSet(constants.ArgPipesHost) {
   278  			ew.Error = sperr.New("Only one of flags --%s and --%s may be set", constants.ArgCloudHost, constants.ArgPipesHost)
   279  			return ew
   280  		}
   281  		viper.Set(constants.ArgPipesHost, viper.GetString(constants.ArgCloudHost))
   282  	}
   283  
   284  	// is deprecated STEAMPIPE_CLOUD_TOKEN env var set?
   285  	if _, isCloudTokenSet := os.LookupEnv(constants.EnvCloudToken); isCloudTokenSet {
   286  		// is PIPES_TOKEN also set? This is an error
   287  		if _, isPipesTokenSet := os.LookupEnv(constants.EnvPipesToken); isPipesTokenSet {
   288  			ew.Error = sperr.New("Only one of env vars %s and %s may be set", constants.EnvCloudToken, constants.EnvPipesToken)
   289  			return ew
   290  		}
   291  		// otherwise, show a warning
   292  		ew.AddWarning(fmt.Sprintf("The %s env var is deprecated - use %s", constants.EnvCloudToken, constants.EnvPipesToken))
   293  	}
   294  	// the same for STEAMPIPE_CLOUD_HOST
   295  	if _, isCloudTokenSet := os.LookupEnv(constants.EnvCloudHost); isCloudTokenSet {
   296  		if _, isPipesTokenSet := os.LookupEnv(constants.EnvPipesHost); isPipesTokenSet {
   297  			ew.Error = sperr.New("Only one of env vars %s and %s may be set", constants.EnvCloudHost, constants.EnvPipesHost)
   298  			return ew
   299  		}
   300  		ew.AddWarning(fmt.Sprintf("The %s env var is deprecated - use %s", constants.EnvCloudHost, constants.EnvPipesHost))
   301  	}
   302  	return ew
   303  }
   304  
   305  func setCloudTokenDefault(loader *steampipeconfig.WorkspaceProfileLoader) error {
   306  	/*
   307  	   saved cloud token
   308  	   cloud_token in default workspace
   309  	   explicit env var (STEAMIPE_CLOUD_TOKEN ) wins over
   310  	   cloud_token in specific workspace
   311  	*/
   312  	// set viper defaults in order of increasing precedence
   313  	// 1) saved cloud token
   314  	savedToken, err := cloud.LoadToken()
   315  	if err != nil {
   316  		return err
   317  	}
   318  	if savedToken != "" {
   319  		viper.SetDefault(constants.ArgPipesToken, savedToken)
   320  	}
   321  	// 2) default profile pipes token
   322  	if loader.DefaultProfile.PipesToken != nil {
   323  		viper.SetDefault(constants.ArgPipesToken, *loader.DefaultProfile.PipesToken)
   324  	}
   325  	// deprecated - cloud token
   326  	if loader.DefaultProfile.CloudToken != nil {
   327  		viper.SetDefault(constants.ArgPipesToken, *loader.DefaultProfile.CloudToken)
   328  	}
   329  	// 3) env var (STEAMIPE_CLOUD_TOKEN )
   330  	SetDefaultFromEnv(constants.EnvPipesToken, constants.ArgPipesToken, String)
   331  	// deprecated env var
   332  	SetDefaultFromEnv(constants.EnvCloudToken, constants.ArgPipesToken, String)
   333  
   334  	// 4) explicit workspace profile
   335  	if p := loader.ConfiguredProfile; p != nil && p.PipesToken != nil {
   336  		viper.SetDefault(constants.ArgPipesToken, *p.PipesToken)
   337  	}
   338  	// deprecated - cloud token
   339  	if p := loader.ConfiguredProfile; p != nil && p.CloudToken != nil {
   340  		viper.SetDefault(constants.ArgPipesToken, *p.CloudToken)
   341  	}
   342  	return nil
   343  }
   344  
   345  func getWorkspaceProfileLoader(ctx context.Context) (*steampipeconfig.WorkspaceProfileLoader, error) {
   346  	// set viper default for workspace profile, using EnvWorkspaceProfile env var
   347  	SetDefaultFromEnv(constants.EnvWorkspaceProfile, constants.ArgWorkspaceProfile, String)
   348  	// set viper default for install dir, using EnvInstallDir env var
   349  	SetDefaultFromEnv(constants.EnvInstallDir, constants.ArgInstallDir, String)
   350  
   351  	// resolve the workspace profile dir
   352  	installDir, err := filehelpers.Tildefy(viper.GetString(constants.ArgInstallDir))
   353  	if err != nil {
   354  		return nil, err
   355  	}
   356  
   357  	workspaceProfileDir, err := filepaths.WorkspaceProfileDir(installDir)
   358  	if err != nil {
   359  		return nil, err
   360  	}
   361  
   362  	// create loader
   363  	loader, err := steampipeconfig.NewWorkspaceProfileLoader(ctx, workspaceProfileDir)
   364  	if err != nil {
   365  		return nil, err
   366  	}
   367  
   368  	return loader, nil
   369  }
   370  
   371  // now validate  config values have appropriate values
   372  // (currently validates telemetry)
   373  func validateConfig() error_helpers.ErrorAndWarnings {
   374  	var res = error_helpers.ErrorAndWarnings{}
   375  	telemetry := viper.GetString(constants.ArgTelemetry)
   376  	if !helpers.StringSliceContains(constants.TelemetryLevels, telemetry) {
   377  		res.Error = sperr.New(`invalid value of 'telemetry' (%s), must be one of: %s`, telemetry, strings.Join(constants.TelemetryLevels, ", "))
   378  		return res
   379  	}
   380  	if _, legacyDiagnosticsSet := os.LookupEnv(plugin.EnvLegacyDiagnosticsLevel); legacyDiagnosticsSet {
   381  		res.AddWarning(fmt.Sprintf("Environment variable %s is deprecated - use %s", plugin.EnvLegacyDiagnosticsLevel, plugin.EnvDiagnosticsLevel))
   382  	}
   383  	res.Error = plugin.ValidateDiagnosticsEnvVar()
   384  
   385  	return res
   386  }
   387  
   388  // create a hclog logger with the level specified by the SP_LOG env var
   389  func createLogger(logBuffer *bytes.Buffer, cmd *cobra.Command) {
   390  	if task.IsPluginManagerCmd(cmd) {
   391  		// nothing to do here - plugin manager sets up it's own logger
   392  		// refer https://github.com/turbot/steampipe/blob/710a96d45fd77294de8d63d77bf78db65133e5ca/cmd/plugin_manager.go#L102
   393  		return
   394  	}
   395  
   396  	level := sdklogging.LogLevel()
   397  	var logDestination io.Writer
   398  	if len(filepaths.SteampipeDir) == 0 {
   399  		// write to the buffer - this is to make sure that we don't lose logs
   400  		// till the time we get the log directory
   401  		logDestination = logBuffer
   402  	} else {
   403  		logDestination = logging.NewRotatingLogWriter(filepaths.EnsureLogDir(), "steampipe")
   404  
   405  		// write out the buffered contents
   406  		_, _ = logDestination.Write(logBuffer.Bytes())
   407  	}
   408  
   409  	hcLevel := hclog.LevelFromString(level)
   410  
   411  	options := &hclog.LoggerOptions{
   412  		// make the name unique so that logs from this instance can be filtered
   413  		Name:       fmt.Sprintf("steampipe [%s]", runtime.ExecutionID),
   414  		Level:      hcLevel,
   415  		Output:     logDestination,
   416  		TimeFn:     func() time.Time { return time.Now().UTC() },
   417  		TimeFormat: "2006-01-02 15:04:05.000 UTC",
   418  	}
   419  	logger := sdklogging.NewLogger(options)
   420  	log.SetOutput(logger.StandardWriter(&hclog.StandardLoggerOptions{InferLevels: true}))
   421  	log.SetPrefix("")
   422  	log.SetFlags(0)
   423  
   424  	// if the buffer is empty then this is the first time the logger is getting setup
   425  	// write out a banner
   426  	if logBuffer.Len() == 0 {
   427  		// pump in the initial set of logs
   428  		// this will also write out the Execution ID - enabling easy filtering of logs for a single execution
   429  		// we need to do this since all instances will log to a single file and logs will be interleaved
   430  		log.Printf("[INFO] ********************************************************\n")
   431  		log.Printf("[INFO] steampipe %s [%s]", cmd.Name(), runtime.ExecutionID)
   432  		log.Printf("[INFO] Version:   v%s\n", version.VersionString)
   433  		log.Printf("[INFO] Log level: %s\n", sdklogging.LogLevel())
   434  		log.Printf("[INFO] Log date:  %s\n", time.Now().Format("2006-01-02"))
   435  		log.Printf("[INFO] ********************************************************\n")
   436  	}
   437  }
   438  
   439  func ensureInstallDir() {
   440  	pipesInstallDir := viper.GetString(constants.ArgPipesInstallDir)
   441  	installDir := viper.GetString(constants.ArgInstallDir)
   442  
   443  	log.Printf("[TRACE] ensureInstallDir %s", installDir)
   444  	if _, err := os.Stat(installDir); os.IsNotExist(err) {
   445  		log.Printf("[TRACE] creating install dir")
   446  		err = os.MkdirAll(installDir, 0755)
   447  		error_helpers.FailOnErrorWithMessage(err, fmt.Sprintf("could not create installation directory: %s", installDir))
   448  	}
   449  
   450  	if _, err := os.Stat(pipesInstallDir); os.IsNotExist(err) {
   451  		log.Printf("[TRACE] creating install dir")
   452  		err = os.MkdirAll(pipesInstallDir, 0755)
   453  		error_helpers.FailOnErrorWithMessage(err, fmt.Sprintf("could not create pipes installation directory: %s", pipesInstallDir))
   454  	}
   455  
   456  	// store as SteampipeDir and PipesInstallDir
   457  	filepaths.SteampipeDir = installDir
   458  	filepaths.PipesInstallDir = pipesInstallDir
   459  }
   460  
   461  // displayDeprecationWarnings shows the deprecated warnings in a formatted way
   462  func displayDeprecationWarnings(errorsAndWarnings error_helpers.ErrorAndWarnings) {
   463  	if len(errorsAndWarnings.Warnings) > 0 {
   464  		fmt.Println(color.YellowString(fmt.Sprintf("\nDeprecation %s:", utils.Pluralize("warning", len(errorsAndWarnings.Warnings)))))
   465  		for _, warning := range errorsAndWarnings.Warnings {
   466  			fmt.Printf("%s\n\n", warning)
   467  		}
   468  		fmt.Println("For more details, see https://steampipe.io/docs/reference/config-files/workspace")
   469  		fmt.Println()
   470  	}
   471  }
   472  
   473  func displayPpDeprecationWarning() {
   474  	fmt.Fprintf(color.Error, "\n%s Steampipe mods and dashboards have been moved to %s. This command %s in a future version. Migration guide - https://powerpipe.io/blog/migrating-from-steampipe \n", color.YellowString("Deprecation warning:"), constants.Bold("Powerpipe"), constants.Bold("will be removed"))
   475  }