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

     1  package workspace
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"errors"
     7  	"fmt"
     8  	"golang.org/x/exp/maps"
     9  	"log"
    10  	"os"
    11  	"path/filepath"
    12  	"strings"
    13  	"sync"
    14  
    15  	"github.com/fatih/color"
    16  	"github.com/fsnotify/fsnotify"
    17  	"github.com/spf13/cobra"
    18  	"github.com/spf13/viper"
    19  	filehelpers "github.com/turbot/go-kit/files"
    20  	"github.com/turbot/go-kit/filewatcher"
    21  	"github.com/turbot/steampipe-plugin-sdk/v5/sperr"
    22  	"github.com/turbot/steampipe/pkg/cmdconfig"
    23  	"github.com/turbot/steampipe/pkg/constants"
    24  	"github.com/turbot/steampipe/pkg/dashboard/dashboardevents"
    25  	"github.com/turbot/steampipe/pkg/db/db_common"
    26  	"github.com/turbot/steampipe/pkg/error_helpers"
    27  	"github.com/turbot/steampipe/pkg/filepaths"
    28  	"github.com/turbot/steampipe/pkg/modinstaller"
    29  	"github.com/turbot/steampipe/pkg/statushooks"
    30  	"github.com/turbot/steampipe/pkg/steampipeconfig"
    31  	"github.com/turbot/steampipe/pkg/steampipeconfig/modconfig"
    32  	"github.com/turbot/steampipe/pkg/steampipeconfig/parse"
    33  	"github.com/turbot/steampipe/pkg/steampipeconfig/versionmap"
    34  	"github.com/turbot/steampipe/pkg/task"
    35  	"github.com/turbot/steampipe/pkg/utils"
    36  )
    37  
    38  type Workspace struct {
    39  	Path                string
    40  	ModInstallationPath string
    41  	Mod                 *modconfig.Mod
    42  
    43  	Mods map[string]*modconfig.Mod
    44  	// the input variables used in the parse
    45  	VariableValues map[string]string
    46  	CloudMetadata  *steampipeconfig.CloudMetadata
    47  
    48  	// source snapshot paths
    49  	// if this is set, no other mod resources are loaded and
    50  	// the ResourceMaps returned by GetModResources will contain only the snapshots
    51  	SourceSnapshots []string
    52  
    53  	watcher     *filewatcher.FileWatcher
    54  	loadLock    sync.Mutex
    55  	exclusions  []string
    56  	modFilePath string
    57  	// should we load/watch files recursively
    58  	listFlag                filehelpers.ListFlag
    59  	fileWatcherErrorHandler func(context.Context, error)
    60  	watcherError            error
    61  	// event handlers
    62  	dashboardEventHandlers []dashboardevents.DashboardEventHandler
    63  	// callback function called when there is a file watcher event
    64  	onFileWatcherEventMessages func()
    65  	loadPseudoResources        bool
    66  	// channel used to send dashboard events to the handleDashboardEvent goroutine
    67  	dashboardEventChan chan dashboardevents.DashboardEvent
    68  }
    69  
    70  // LoadWorkspaceVars creates a Workspace and loads the variables
    71  func LoadWorkspaceVars(ctx context.Context) (*Workspace, *modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) {
    72  	log.Printf("[INFO] LoadWorkspaceVars: creating workspace, loading variable and resolving variable values")
    73  	workspacePath := viper.GetString(constants.ArgModLocation)
    74  
    75  	utils.LogTime("workspace.Load start")
    76  	defer utils.LogTime("workspace.Load end")
    77  
    78  	workspace, err := createShellWorkspace(workspacePath)
    79  	if err != nil {
    80  		log.Printf("[INFO] createShellWorkspace failed %s", err.Error())
    81  		return nil, nil, error_helpers.NewErrorsAndWarning(err)
    82  	}
    83  
    84  	// check if your workspace path is home dir and if modfile exists - if yes then warn and ask user to continue or not
    85  	if err := HomeDirectoryModfileCheck(ctx, workspacePath); err != nil {
    86  		log.Printf("[INFO] HomeDirectoryModfileCheck failed %s", err.Error())
    87  		return nil, nil, error_helpers.NewErrorsAndWarning(err)
    88  	}
    89  	inputVariables, errorsAndWarnings := workspace.PopulateVariables(ctx)
    90  	if errorsAndWarnings.Error != nil {
    91  		log.Printf("[WARN] PopulateVariables failed %s", errorsAndWarnings.Error.Error())
    92  		return nil, nil, errorsAndWarnings
    93  	}
    94  
    95  	log.Printf("[INFO] LoadWorkspaceVars succededed - got values for vars: %s", strings.Join(maps.Keys(workspace.VariableValues), ", "))
    96  
    97  	return workspace, inputVariables, errorsAndWarnings
    98  }
    99  
   100  // LoadVariables creates a Workspace and uses it to load all variables, ignoring any value resolution errors
   101  // this is use for the variable list command
   102  func LoadVariables(ctx context.Context, workspacePath string) ([]*modconfig.Variable, error_helpers.ErrorAndWarnings) {
   103  	utils.LogTime("workspace.LoadVariables start")
   104  	defer utils.LogTime("workspace.LoadVariables end")
   105  
   106  	// create shell workspace
   107  	workspace, err := createShellWorkspace(workspacePath)
   108  	if err != nil {
   109  		return nil, error_helpers.NewErrorsAndWarning(err)
   110  	}
   111  
   112  	// resolve variables values, WITHOUT validating missing vars
   113  	validateMissing := false
   114  	variableMap, errorAndWarnings := workspace.getInputVariables(ctx, validateMissing)
   115  	if errorAndWarnings.Error != nil {
   116  		return nil, errorAndWarnings
   117  	}
   118  
   119  	// convert into a sorted array
   120  	return variableMap.ToArray(), errorAndWarnings
   121  }
   122  
   123  func createShellWorkspace(workspacePath string) (*Workspace, error) {
   124  	// create shell workspace
   125  	workspace := &Workspace{
   126  		Path:           workspacePath,
   127  		VariableValues: make(map[string]string),
   128  	}
   129  
   130  	// check whether the workspace contains a modfile
   131  	// this will determine whether we load files recursively, and create pseudo resources for sql files
   132  	workspace.setModfileExists()
   133  
   134  	// load the .steampipe ignore file
   135  	if err := workspace.loadExclusions(); err != nil {
   136  		return nil, err
   137  	}
   138  
   139  	return workspace, nil
   140  }
   141  
   142  // LoadResourceNames builds lists of all workspace resource names
   143  func LoadResourceNames(ctx context.Context, workspacePath string) (*modconfig.WorkspaceResources, error) {
   144  	utils.LogTime("workspace.LoadResourceNames start")
   145  	defer utils.LogTime("workspace.LoadResourceNames end")
   146  
   147  	// create shell workspace
   148  	workspace := &Workspace{
   149  		Path: workspacePath,
   150  	}
   151  
   152  	// determine whether to load files recursively or just from the top level folder
   153  	workspace.setModfileExists()
   154  
   155  	// load the .steampipe ignore file
   156  	if err := workspace.loadExclusions(); err != nil {
   157  		return nil, err
   158  	}
   159  
   160  	return workspace.loadWorkspaceResourceName(ctx)
   161  }
   162  
   163  func (w *Workspace) SetupWatcher(ctx context.Context, client db_common.Client, errorHandler func(context.Context, error)) error {
   164  	watcherOptions := &filewatcher.WatcherOptions{
   165  		Directories: []string{w.Path},
   166  		Include:     filehelpers.InclusionsFromExtensions(steampipeconfig.GetModFileExtensions()),
   167  		Exclude:     w.exclusions,
   168  		ListFlag:    w.listFlag,
   169  		EventMask:   fsnotify.Create | fsnotify.Remove | fsnotify.Rename | fsnotify.Write,
   170  		// we should look into passing the callback function into the underlying watcher
   171  		// we need to analyze the kind of errors that come out from the watcher and
   172  		// decide how to handle them
   173  		// OnError: errCallback,
   174  		OnChange: func(events []fsnotify.Event) {
   175  			w.handleFileWatcherEvent(ctx, client, events)
   176  		},
   177  	}
   178  	watcher, err := filewatcher.NewWatcher(watcherOptions)
   179  	if err != nil {
   180  		return err
   181  	}
   182  	w.watcher = watcher
   183  	// start the watcher
   184  	watcher.Start()
   185  
   186  	// set the file watcher error handler, which will get called when there are parsing errors
   187  	// after a file watcher event
   188  	w.fileWatcherErrorHandler = errorHandler
   189  	if w.fileWatcherErrorHandler == nil {
   190  		w.fileWatcherErrorHandler = func(ctx context.Context, err error) {
   191  			fmt.Println()
   192  			error_helpers.ShowErrorWithMessage(ctx, err, "failed to reload mod from file watcher")
   193  		}
   194  	}
   195  
   196  	return nil
   197  }
   198  
   199  func (w *Workspace) SetOnFileWatcherEventMessages(f func()) {
   200  	w.onFileWatcherEventMessages = f
   201  }
   202  
   203  // access functions
   204  // NOTE: all access functions lock 'loadLock' - this is to avoid conflicts with the file watcher
   205  
   206  func (w *Workspace) Close() {
   207  	if w.watcher != nil {
   208  		w.watcher.Close()
   209  	}
   210  	if ch := w.dashboardEventChan; ch != nil {
   211  		// NOTE: set nil first
   212  		w.dashboardEventChan = nil
   213  		log.Printf("[TRACE] closing dashboardEventChan")
   214  		close(ch)
   215  	}
   216  }
   217  
   218  func (w *Workspace) ModfileExists() bool {
   219  	return len(w.modFilePath) > 0
   220  }
   221  
   222  // check  whether the workspace contains a modfile
   223  // this will determine whether we load files recursively, and create pseudo resources for sql files
   224  func (w *Workspace) setModfileExists() {
   225  	modFile, err := FindModFilePath(w.Path)
   226  	modFileExists := err != ErrorNoModDefinition
   227  
   228  	if modFileExists {
   229  		log.Printf("[TRACE] modfile exists in workspace folder - creating pseudo-resources and loading files recursively ")
   230  		// only load/watch recursively if a mod sp file exists in the workspace folder
   231  		w.listFlag = filehelpers.FilesRecursive
   232  		w.loadPseudoResources = true
   233  		w.modFilePath = modFile
   234  
   235  		// also set it in the viper config, so that it is available to whoever is using it
   236  		viper.Set(constants.ArgModLocation, filepath.Dir(modFile))
   237  		w.Path = filepath.Dir(modFile)
   238  	} else {
   239  		log.Printf("[TRACE] no modfile exists in workspace folder - NOT creating pseudoresources and only loading resource files from top level folder")
   240  		w.listFlag = filehelpers.Files
   241  		w.loadPseudoResources = false
   242  	}
   243  }
   244  
   245  // FindModFilePath looks in the current folder for mod.sp
   246  // if not found it looks in the parent folder - right up to the root
   247  func FindModFilePath(folder string) (string, error) {
   248  	folder, err := filepath.Abs(folder)
   249  	if err != nil {
   250  		return "", err
   251  	}
   252  	for _, modFilePath := range filepaths.ModFilePaths(folder) {
   253  		_, err = os.Stat(modFilePath)
   254  		if err == nil {
   255  			// found the modfile
   256  			return modFilePath, nil
   257  		}
   258  	}
   259  
   260  	// if the file wasn't found, search in the parent directory
   261  	parent := filepath.Dir(folder)
   262  	if folder == parent {
   263  		// this typically means that we are already in the root directory
   264  		return "", ErrorNoModDefinition
   265  	}
   266  	return FindModFilePath(filepath.Dir(folder))
   267  }
   268  
   269  func HomeDirectoryModfileCheck(ctx context.Context, workspacePath string) error {
   270  	// bypass all the checks if ConfigKeyBypassHomeDirModfileWarning is set - it means home dir modfile check
   271  	// has already happened before
   272  	if viper.GetBool(constants.ConfigKeyBypassHomeDirModfileWarning) {
   273  		return nil
   274  	}
   275  	// get the cmd and home dir
   276  	cmd := viper.Get(constants.ConfigKeyActiveCommand).(*cobra.Command)
   277  	home, _ := os.UserHomeDir()
   278  
   279  	var modFileExists bool
   280  	for _, modFilePath := range filepaths.ModFilePaths(workspacePath) {
   281  		if _, err := os.Stat(modFilePath); err == nil {
   282  			modFileExists = true
   283  		}
   284  	}
   285  
   286  	// check if your workspace path is home dir and if modfile exists
   287  	if workspacePath == home && modFileExists {
   288  		// for interactive query - ask for confirmation to continue
   289  		if cmd.Name() == "query" && viper.GetBool(constants.ConfigKeyInteractive) {
   290  			confirm, err := utils.UserConfirmation(ctx, fmt.Sprintf("%s: You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nDo you still want to continue? (y/n)", color.YellowString("Warning")))
   291  			if err != nil {
   292  				return err
   293  			}
   294  			if !confirm {
   295  				return sperr.New("failed to load workspace: execution cancelled")
   296  			}
   297  			return nil
   298  		}
   299  
   300  		// for batch query mode - if output is table, just warn
   301  		if task.IsBatchQueryCmd(cmd, viper.GetStringSlice(constants.ConfigKeyActiveCommandArgs)) && cmdconfig.Viper().GetString(constants.ArgOutput) == constants.OutputFormatTable {
   302  			error_helpers.ShowWarning("You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nHit Ctrl+C to stop.\n")
   303  			return nil
   304  		}
   305  
   306  		// for other cmds - if home dir has modfile, just warn
   307  		error_helpers.ShowWarning("You have a mod.sp file in your home directory. This is not recommended.\nAs a result, steampipe will try to load all the files in home and its sub-directories, which can cause performance issues.\nBest practice is to put mod.sp files in their own directories.\nHit Ctrl+C to stop.\n")
   308  	}
   309  
   310  	return nil
   311  }
   312  
   313  func (w *Workspace) LoadWorkspaceMod(ctx context.Context, inputVariables *modconfig.ModVariableMap) error_helpers.ErrorAndWarnings {
   314  	var errorsAndWarnings = error_helpers.ErrorAndWarnings{}
   315  
   316  	// build run context which we use to load the workspace
   317  	parseCtx, err := w.getParseContext(ctx, inputVariables)
   318  	if err != nil {
   319  		errorsAndWarnings.Error = err
   320  		return errorsAndWarnings
   321  	}
   322  
   323  	// do not reload variables as we already have them
   324  	parseCtx.BlockTypeExclusions = []string{modconfig.BlockTypeVariable}
   325  
   326  	// load the workspace mod
   327  	m, otherErrorAndWarning := steampipeconfig.LoadMod(ctx, w.Path, parseCtx)
   328  	errorsAndWarnings.Merge(otherErrorAndWarning)
   329  	if errorsAndWarnings.Error != nil {
   330  		return errorsAndWarnings
   331  	}
   332  
   333  	// now set workspace properties
   334  	// populate the mod references map references
   335  	m.ResourceMaps.PopulateReferences()
   336  	// set the mod
   337  	w.Mod = m
   338  	// set the child mods
   339  	w.Mods = parseCtx.GetTopLevelDependencyMods()
   340  	// NOTE: add in the workspace mod to the dependency mods
   341  	w.Mods[w.Mod.Name()] = w.Mod
   342  
   343  	// verify all runtime dependencies can be resolved
   344  	errorsAndWarnings.Error = w.verifyResourceRuntimeDependencies()
   345  	return errorsAndWarnings
   346  }
   347  
   348  func (w *Workspace) PopulateVariables(ctx context.Context) (*modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) {
   349  	log.Printf("[TRACE] Workspace.PopulateVariables")
   350  	// resolve values of all input variables
   351  	// we WILL validate missing variables when loading
   352  	validateMissing := true
   353  	inputVariables, errorsAndWarnings := w.getInputVariables(ctx, validateMissing)
   354  	if errorsAndWarnings.Error != nil {
   355  		// so there was an error - was it missing variables error
   356  		var missingVariablesError steampipeconfig.MissingVariableError
   357  		ok := errors.As(errorsAndWarnings.GetError(), &missingVariablesError)
   358  		// if there was an error which is NOT a MissingVariableError, return it
   359  		if !ok {
   360  			return nil, errorsAndWarnings
   361  		}
   362  		// if there are missing transitive dependency variables, fail as we do not prompt for these
   363  		if len(missingVariablesError.MissingTransitiveVariables) > 0 {
   364  			return nil, errorsAndWarnings
   365  		}
   366  		// if interactive input is disabled, return the missing variables error
   367  		if !viper.GetBool(constants.ArgInput) {
   368  			return nil, error_helpers.NewErrorsAndWarning(missingVariablesError)
   369  		}
   370  		// so we have missing variables - prompt for them
   371  		// first hide spinner if it is there
   372  		statushooks.Done(ctx)
   373  		if err := promptForMissingVariables(ctx, missingVariablesError.MissingVariables, w.Path); err != nil {
   374  			log.Printf("[TRACE] Interactive variables prompting returned error %v", err)
   375  			return nil, error_helpers.NewErrorsAndWarning(err)
   376  		}
   377  
   378  		// now try to load vars again
   379  		inputVariables, errorsAndWarnings = w.getInputVariables(ctx, validateMissing)
   380  		if errorsAndWarnings.Error != nil {
   381  			return nil, errorsAndWarnings
   382  		}
   383  
   384  	}
   385  	// populate the parsed variable values
   386  	w.VariableValues, errorsAndWarnings.Error = inputVariables.GetPublicVariableValues()
   387  
   388  	return inputVariables, errorsAndWarnings
   389  }
   390  
   391  func (w *Workspace) getInputVariables(ctx context.Context, validateMissing bool) (*modconfig.ModVariableMap, error_helpers.ErrorAndWarnings) {
   392  	log.Printf("[TRACE] Workspace.getInputVariables")
   393  	// build a run context just to use to load variable definitions
   394  	variablesParseCtx, err := w.getParseContext(ctx, nil)
   395  	if err != nil {
   396  		return nil, error_helpers.NewErrorsAndWarning(err)
   397  	}
   398  
   399  	// load variable definitions
   400  	variableMap, err := steampipeconfig.LoadVariableDefinitions(ctx, w.Path, variablesParseCtx)
   401  	if err != nil {
   402  		return nil, error_helpers.NewErrorsAndWarning(err)
   403  	}
   404  
   405  	log.Printf("[INFO] loaded variable definitions: %s", variableMap)
   406  
   407  	// get the values
   408  	return steampipeconfig.GetVariableValues(variablesParseCtx, variableMap, validateMissing)
   409  }
   410  
   411  // build options used to load workspace
   412  // set flags to create pseudo resources and a default mod if needed
   413  func (w *Workspace) getParseContext(ctx context.Context, variables *modconfig.ModVariableMap) (*parse.ModParseContext, error) {
   414  	parseFlag := parse.CreateDefaultMod
   415  	if w.loadPseudoResources {
   416  		parseFlag |= parse.CreatePseudoResources
   417  	}
   418  	workspaceLock, err := w.loadWorkspaceLock(ctx)
   419  	if err != nil {
   420  		return nil, err
   421  	}
   422  	parseCtx := parse.NewModParseContext(
   423  		workspaceLock,
   424  		w.Path,
   425  		parseFlag,
   426  		&filehelpers.ListOptions{
   427  			// listFlag specifies whether to load files recursively
   428  			Flags:   w.listFlag,
   429  			Exclude: w.exclusions,
   430  			// only load .sp files
   431  			Include: filehelpers.InclusionsFromExtensions(constants.ModDataExtensions),
   432  		})
   433  
   434  	// add any evaluated variables to the context
   435  	if variables != nil {
   436  		parseCtx.AddInputVariableValues(variables)
   437  	}
   438  
   439  	return parseCtx, nil
   440  }
   441  
   442  // load the workspace lock, migrating it if necessary
   443  func (w *Workspace) loadWorkspaceLock(ctx context.Context) (*versionmap.WorkspaceLock, error) {
   444  	workspaceLock, err := versionmap.LoadWorkspaceLock(ctx, w.Path)
   445  	if err != nil {
   446  		return nil, fmt.Errorf("failed to load installation cache from %s: %s", w.Path, err)
   447  	}
   448  
   449  	// if this is the old format, migrate by reinstalling dependencies
   450  	if workspaceLock.StructVersion() != versionmap.WorkspaceLockStructVersion {
   451  		opts := &modinstaller.InstallOpts{WorkspaceMod: w.Mod}
   452  		installData, err := modinstaller.InstallWorkspaceDependencies(ctx, opts)
   453  		if err != nil {
   454  			return nil, err
   455  		}
   456  		workspaceLock = installData.NewLock
   457  	}
   458  	return workspaceLock, nil
   459  }
   460  
   461  func (w *Workspace) loadExclusions() error {
   462  	// default to ignoring hidden files and folders
   463  	w.exclusions = []string{
   464  		// ignore any hidden folder
   465  		fmt.Sprintf("%s/.*", w.Path),
   466  		// and sub files/folders of hidden folders
   467  		fmt.Sprintf("%s/.*/**", w.Path),
   468  	}
   469  
   470  	ignorePath := filepath.Join(w.Path, filepaths.WorkspaceIgnoreFile)
   471  	file, err := os.Open(ignorePath)
   472  	if err != nil {
   473  		// if file does not exist, just return
   474  		if os.IsNotExist(err) {
   475  			return nil
   476  		}
   477  		return err
   478  	}
   479  	defer file.Close()
   480  
   481  	scanner := bufio.NewScanner(file)
   482  	for scanner.Scan() {
   483  		line := scanner.Text()
   484  		if len(strings.TrimSpace(line)) != 0 && !strings.HasPrefix(line, "#") {
   485  			// add exclusion to the workspace path (to ensure relative patterns work)
   486  			absoluteExclusion := filepath.Join(w.Path, line)
   487  			w.exclusions = append(w.exclusions, absoluteExclusion)
   488  		}
   489  	}
   490  
   491  	if err = scanner.Err(); err != nil {
   492  		return err
   493  	}
   494  
   495  	return nil
   496  }
   497  
   498  func (w *Workspace) loadWorkspaceResourceName(ctx context.Context) (*modconfig.WorkspaceResources, error) {
   499  	// build options used to load workspace
   500  	parseCtx, err := w.getParseContext(ctx, nil)
   501  	if err != nil {
   502  		return nil, err
   503  	}
   504  
   505  	workspaceResourceNames, err := steampipeconfig.LoadModResourceNames(ctx, w.Mod, parseCtx)
   506  	if err != nil {
   507  		return nil, err
   508  	}
   509  
   510  	return workspaceResourceNames, nil
   511  }
   512  
   513  func (w *Workspace) verifyResourceRuntimeDependencies() error {
   514  	for _, d := range w.Mod.ResourceMaps.Dashboards {
   515  		if err := d.ValidateRuntimeDependencies(w); err != nil {
   516  			return err
   517  		}
   518  	}
   519  	return nil
   520  }